switchyard_protocol/llm.rs
1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Provider-neutral conversation types shared by routing, clients, and translation.
5
6use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10
11use crate::format::FormatId;
12
13/// Actor role normalized across provider APIs.
14#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum Role {
17 /// System-level instructions.
18 System,
19 /// Developer-level instructions supported by some provider APIs.
20 Developer,
21 /// End-user input.
22 User,
23 /// Model-generated output.
24 Assistant,
25 /// Tool execution output.
26 Tool,
27}
28
29/// Instruction content separated from normal conversation messages.
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31pub struct InstructionBlock {
32 /// Instruction authority, normally [`Role::System`] or [`Role::Developer`].
33 pub role: Role,
34 /// Ordered instruction content.
35 pub content: Vec<ContentBlock>,
36}
37
38/// One normalized conversation message.
39#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
40pub struct Message {
41 /// Actor that produced the message.
42 pub role: Role,
43 /// Ordered message content.
44 pub content: Vec<ContentBlock>,
45}
46
47impl Message {
48 /// Creates a text-only message for the given role.
49 pub fn text(role: Role, text: impl Into<String>) -> Self {
50 Self {
51 role,
52 content: vec![ContentBlock::Text { text: text.into() }],
53 }
54 }
55
56 /// Concatenates text-like content blocks when the message has any.
57 pub fn text_content(&self, separator: &str) -> Option<String> {
58 let parts = self
59 .content
60 .iter()
61 .filter_map(|block| match block {
62 ContentBlock::Text { text } => Some(text.as_str()),
63 ContentBlock::Refusal { text } => Some(text.as_str()),
64 _ => None,
65 })
66 .collect::<Vec<_>>();
67 if parts.is_empty() {
68 None
69 } else {
70 Some(parts.join(separator))
71 }
72 }
73}
74
75/// Normalized content block variants carried by messages and tool results.
76#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
77#[serde(tag = "type", rename_all = "snake_case")]
78pub enum ContentBlock {
79 /// Plain text content.
80 Text {
81 /// Text value.
82 text: String,
83 },
84 /// Model reasoning or thinking content.
85 Reasoning {
86 /// Reasoning text.
87 text: String,
88 /// Provider signature used to validate or continue the reasoning block.
89 signature: Option<String>,
90 },
91 /// Image content.
92 Image {
93 /// Image location or inline payload.
94 source: ImageSource,
95 },
96 /// Audio content.
97 Audio {
98 /// Audio location or inline payload.
99 source: MediaSource,
100 },
101 /// Video content.
102 Video {
103 /// Video location or inline payload.
104 source: MediaSource,
105 },
106 /// File content.
107 File {
108 /// File identifier or inline payload.
109 source: FileSource,
110 },
111 /// Tool invocation requested by the assistant.
112 ToolCall(ToolCall),
113 /// Result of an earlier tool invocation.
114 ToolResult(ToolResult),
115 /// Provider refusal content.
116 Refusal {
117 /// Human-readable refusal text.
118 text: String,
119 },
120 /// Provider block that has no normalized representation.
121 Unknown {
122 /// Wire format that supplied the block.
123 provider: FormatId,
124 /// Exact provider block.
125 raw: Value,
126 },
127}
128
129/// Image payload forms supported by the conversation model.
130#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
131#[serde(tag = "type", content = "data", rename_all = "snake_case")]
132pub enum ImageSource {
133 /// Image fetched from a URL.
134 Url {
135 /// Image URL.
136 url: String,
137 /// Optional provider-specific image detail level.
138 detail: Option<String>,
139 },
140 /// Base64-encoded image bytes.
141 Base64 {
142 /// MIME type, when supplied.
143 media_type: Option<String>,
144 /// Base64-encoded bytes.
145 data: String,
146 },
147 /// Provider image source with no normalized representation.
148 Raw(Value),
149}
150
151/// File payload forms supported by the conversation model.
152#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
153#[serde(tag = "type", content = "data", rename_all = "snake_case")]
154pub enum FileSource {
155 /// Provider-managed file identifier.
156 FileId(String),
157 /// Inline file payload.
158 FileData {
159 /// Encoded or textual file data as supplied by the provider format.
160 data: String,
161 /// Original filename, when supplied.
162 filename: Option<String>,
163 },
164 /// Provider file source with no normalized representation.
165 Raw(Value),
166}
167
168/// Audio and video payload forms supported by the conversation model.
169#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
170#[serde(tag = "type", content = "data", rename_all = "snake_case")]
171pub enum MediaSource {
172 /// Media fetched from a URL.
173 Url {
174 /// Media URL.
175 url: String,
176 /// MIME type, when supplied.
177 media_type: Option<String>,
178 },
179 /// Base64-encoded media bytes.
180 Base64 {
181 /// MIME type, when supplied.
182 media_type: Option<String>,
183 /// Base64-encoded bytes.
184 data: String,
185 },
186 /// Provider media source with no normalized representation.
187 Raw(Value),
188}
189
190/// Normalized assistant tool call.
191#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
192pub struct ToolCall {
193 /// Provider tool-call identifier used to pair the result.
194 pub id: String,
195 /// Tool name.
196 pub name: String,
197 /// Parsed tool arguments.
198 pub arguments: Value,
199}
200
201/// Normalized tool result message content.
202#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
203pub struct ToolResult {
204 /// Identifier of the [`ToolCall`] this result answers.
205 pub tool_call_id: String,
206 /// Ordered tool output content.
207 pub content: Vec<ContentBlock>,
208 /// Whether tool execution failed, when the provider reports it.
209 pub is_error: Option<bool>,
210}
211
212/// Normalized tool definition.
213#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
214pub struct ToolDefinition {
215 /// Tool name exposed to the model.
216 pub name: String,
217 /// Human-readable tool description.
218 pub description: Option<String>,
219 /// JSON Schema describing accepted arguments.
220 pub parameters: Value,
221 /// Whether the provider should enforce the schema strictly.
222 pub strict: Option<bool>,
223}
224
225/// Normalized tool choice policy.
226#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
227#[serde(tag = "type", content = "data", rename_all = "snake_case")]
228pub enum ToolChoice {
229 /// Let the model decide whether to call a tool.
230 Auto,
231 /// Require at least one tool call.
232 Required,
233 /// Disallow tool calls.
234 None,
235 /// Require a specific tool.
236 Tool {
237 /// Required tool name.
238 name: String,
239 },
240 /// Provider tool-choice value with no normalized representation.
241 Raw(Value),
242}
243
244/// Provider sampling parameters with common cross-provider names.
245#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
246pub struct SamplingParams {
247 /// Sampling temperature.
248 pub temperature: Option<f64>,
249 /// Nucleus-sampling probability mass.
250 pub top_p: Option<f64>,
251 /// Maximum number of candidate tokens considered at each step.
252 pub top_k: Option<i64>,
253}
254
255/// Output budget and structured-output options.
256#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
257pub struct OutputParams {
258 /// Maximum tokens the model may generate.
259 pub max_output_tokens: Option<u64>,
260 /// Provider-neutral or provider-specific structured-output configuration.
261 pub response_format: Option<Value>,
262}
263
264/// Provider reasoning controls preserved by translation.
265#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
266pub struct ReasoningParams {
267 /// Requested reasoning effort or level.
268 pub effort: Option<String>,
269 /// Provider reasoning controls without a normalized field.
270 pub raw: Option<Value>,
271}
272
273/// Provider-specific fields that do not have first-class conversation fields.
274#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
275#[serde(default)]
276pub struct ProviderExtensions {
277 /// Provider fields keyed by their original wire names.
278 ///
279 /// Codecs use these fields when translating to another format. Exact
280 /// same-format replay is controlled separately by [`PreservationMetadata`].
281 pub fields: Map<String, Value>,
282}
283
284/// Exact source payloads retained for lossless same-format round trips.
285///
286/// Translation's default preservation policy prefers a stored same-format body
287/// over reconstructing one from normalized fields. A caller that mutates the IR
288/// must clear the corresponding entry or use a policy with preservation disabled
289/// when those mutations must be encoded.
290#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
291#[serde(default)]
292pub struct PreservationMetadata {
293 /// Original request bodies keyed by source format.
294 pub requests: BTreeMap<FormatId, Value>,
295 /// Original response bodies keyed by source format.
296 pub responses: BTreeMap<FormatId, Value>,
297}
298
299/// Normalized request representation shared by Switchyard components.
300#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
301#[serde(default)]
302pub struct LlmRequest {
303 /// Model currently addressed by the request.
304 ///
305 /// This initially contains the name supplied by the inbound client. A routing host may
306 /// replace it with the selected target before serving the request.
307 pub model: Option<String>,
308 /// System and developer instructions separated from conversation turns.
309 pub instructions: Vec<InstructionBlock>,
310 /// Ordered conversation messages.
311 pub messages: Vec<Message>,
312 /// Tools available to the model.
313 pub tools: Vec<ToolDefinition>,
314 /// Policy controlling model tool selection.
315 pub tool_choice: Option<ToolChoice>,
316 /// Common sampling controls.
317 pub sampling: SamplingParams,
318 /// Output budget and shape controls.
319 pub output: OutputParams,
320 /// Reasoning controls.
321 pub reasoning: ReasoningParams,
322 /// Whether the caller requested a streamed response.
323 pub stream: bool,
324 /// Provider fields without first-class normalized equivalents.
325 pub extensions: ProviderExtensions,
326 /// Exact provider bodies used by codecs for lossless same-format round trips.
327 /// This is separate from a host's optional
328 /// [`Request::raw_request`](crate::Request::raw_request).
329 pub preservation: PreservationMetadata,
330}
331
332/// Normalized token usage counts.
333#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
334pub struct Usage {
335 /// Non-cached input tokens. Provider codecs normalize aggregate OpenAI
336 /// input counts by subtracting the cache detail fields.
337 pub input_tokens: Option<u64>,
338 /// Cache-read and cache-creation token detail, when reported.
339 #[serde(flatten)]
340 pub cache: Option<Box<InputCacheUsage>>,
341 /// Generated output tokens, excluding reasoning detail when the provider reports it separately.
342 pub output_tokens: Option<u64>,
343 /// Provider-reported or codec-computed total token count.
344 ///
345 /// Codecs normalize OpenAI aggregate input counts before computing totals, so
346 /// this may equal non-cached input plus cache detail plus output.
347 pub total_tokens: Option<u64>,
348 /// Reasoning output tokens, when reported separately.
349 pub reasoning_tokens: Option<u64>,
350}
351
352/// Optional cache-token detail kept out of the common, cache-free usage allocation.
353#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
354pub struct InputCacheUsage {
355 /// Input tokens read from a provider cache.
356 pub cached_input_tokens: Option<u64>,
357 /// Input tokens written into a provider cache.
358 pub cache_creation_input_tokens: Option<u64>,
359}
360
361impl Usage {
362 /// Builds cache detail when at least one count is present.
363 pub fn cache_details(
364 cached_input_tokens: Option<u64>,
365 cache_creation_input_tokens: Option<u64>,
366 ) -> Option<Box<InputCacheUsage>> {
367 if cached_input_tokens.is_none() && cache_creation_input_tokens.is_none() {
368 return None;
369 }
370 Some(Box::new(InputCacheUsage {
371 cached_input_tokens,
372 cache_creation_input_tokens,
373 }))
374 }
375
376 /// Returns the cache-read input-token count.
377 pub fn cached_input_tokens(&self) -> Option<u64> {
378 self.cache
379 .as_ref()
380 .and_then(|cache| cache.cached_input_tokens)
381 }
382
383 /// Returns the cache-creation input-token count.
384 pub fn cache_creation_input_tokens(&self) -> Option<u64> {
385 self.cache
386 .as_ref()
387 .and_then(|cache| cache.cache_creation_input_tokens)
388 }
389
390 /// Sets the cache-read count, allocating cache detail when needed.
391 pub fn set_cached_input_tokens(&mut self, value: u64) {
392 self.cache
393 .get_or_insert_with(|| Box::new(InputCacheUsage::default()))
394 .cached_input_tokens = Some(value);
395 }
396
397 /// Sets the cache-creation count, allocating cache detail when needed.
398 pub fn set_cache_creation_input_tokens(&mut self, value: u64) {
399 self.cache
400 .get_or_insert_with(|| Box::new(InputCacheUsage::default()))
401 .cache_creation_input_tokens = Some(value);
402 }
403}
404
405/// Normalized reason a model stopped producing output.
406#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
407#[serde(rename_all = "snake_case")]
408pub enum StopReason {
409 /// Model completed its turn normally.
410 EndTurn,
411 /// Model reached the configured output limit.
412 MaxTokens,
413 /// Model stopped to request a tool call.
414 ToolUse,
415 /// Provider safety or content filtering stopped generation.
416 ContentFilter,
417 /// Generation terminated because of an error.
418 Error,
419 /// Provider stop reason with no normalized equivalent.
420 Unknown,
421}
422
423/// One assistant output item in a normalized response.
424#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
425pub struct ResponseOutput {
426 /// Actor that produced the output, normally [`Role::Assistant`].
427 pub role: Role,
428 /// Ordered output content.
429 pub content: Vec<ContentBlock>,
430 /// Why this output stopped, when known.
431 pub stop_reason: Option<StopReason>,
432}
433
434/// Normalized, fully-buffered response — the aggregate of a completed generation.
435/// This is the terminal form of a streamed [`LlmResponse`](crate::LlmResponse).
436#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
437#[serde(default)]
438pub struct AggLlmResponse {
439 /// Provider response identifier.
440 pub id: Option<String>,
441 /// Model reported by the provider.
442 pub model: Option<String>,
443 /// Ordered response output items.
444 pub outputs: Vec<ResponseOutput>,
445 /// Normalized token usage.
446 pub usage: Usage,
447 /// Provider response fields without normalized equivalents.
448 pub extensions: ProviderExtensions,
449 /// Exact provider bodies retained for lossless round trips.
450 pub preservation: PreservationMetadata,
451}
452
453impl AggLlmResponse {
454 /// Returns the first output item when a response has any output.
455 pub fn first_output(&self) -> Option<&ResponseOutput> {
456 self.outputs.first()
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use serde_json::json;
463
464 use super::*;
465
466 #[test]
467 fn serde_uses_python_friendly_dictionary_shapes() -> Result<(), serde_json::Error> {
468 let request: LlmRequest = serde_json::from_value(json!({
469 "model": "auto",
470 "messages": [{
471 "role": "user",
472 "content": [{"type": "text", "text": "hello"}]
473 }]
474 }))?;
475 assert_eq!(request.messages[0], Message::text(Role::User, "hello"));
476
477 let tool_call = ContentBlock::ToolCall(ToolCall {
478 id: "call-1".to_string(),
479 name: "lookup".to_string(),
480 arguments: json!({"query": "rust"}),
481 });
482 assert_eq!(
483 serde_json::to_value(tool_call)?,
484 json!({
485 "type": "tool_call",
486 "id": "call-1",
487 "name": "lookup",
488 "arguments": {"query": "rust"}
489 })
490 );
491 Ok(())
492 }
493}