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