Skip to main content

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//!
6//! Debug output redacts MCP URLs and authorization tokens and hides raw provider tool definitions,
7//! extensions, and preserved bodies. Conversation content, including unknown provider
8//! blocks, remains visible and may contain secrets. Serialization retains the stored values.
9
10use std::collections::BTreeMap;
11use std::fmt;
12
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16use crate::format::FormatId;
17
18/// Actor role normalized across provider APIs.
19#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum Role {
22    /// System-level instructions.
23    System,
24    /// Developer-level instructions supported by some provider APIs.
25    Developer,
26    /// End-user input.
27    User,
28    /// Model-generated output.
29    Assistant,
30    /// Tool execution output.
31    Tool,
32}
33
34/// Instruction content separated from normal conversation messages.
35#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
36pub struct InstructionBlock {
37    /// Instruction authority, normally [`Role::System`] or [`Role::Developer`].
38    pub role: Role,
39    /// Ordered instruction content.
40    pub content: Vec<ContentBlock>,
41}
42
43/// One normalized conversation message.
44#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
45pub struct Message {
46    /// Actor that produced the message.
47    pub role: Role,
48    /// Ordered message content.
49    pub content: Vec<ContentBlock>,
50}
51
52impl Message {
53    /// Creates a text-only message for the given role.
54    pub fn text(role: Role, text: impl Into<String>) -> Self {
55        Self {
56            role,
57            content: vec![ContentBlock::Text { text: text.into() }],
58        }
59    }
60
61    /// Concatenates text-like content blocks when the message has any.
62    pub fn text_content(&self, separator: &str) -> Option<String> {
63        let parts = self
64            .content
65            .iter()
66            .filter_map(|block| match block {
67                ContentBlock::Text { text } => Some(text.as_str()),
68                ContentBlock::Refusal { text } => Some(text.as_str()),
69                _ => None,
70            })
71            .collect::<Vec<_>>();
72        if parts.is_empty() {
73            None
74        } else {
75            Some(parts.join(separator))
76        }
77    }
78}
79
80/// Normalized content block variants carried by messages and tool results.
81#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
82#[serde(tag = "type", rename_all = "snake_case")]
83pub enum ContentBlock {
84    /// Plain text content.
85    Text {
86        /// Text value.
87        text: String,
88    },
89    /// Model reasoning or thinking content.
90    Reasoning {
91        /// Reasoning text.
92        text: String,
93        /// Provider signature used to validate or continue the reasoning block.
94        signature: Option<String>,
95        /// Structured reasoning details, such as an encrypted `{ "type":
96        /// "reasoning.encrypted", "data": "..." }` object, replayed without modification.
97        #[serde(default, skip_serializing_if = "Vec::is_empty")]
98        details: Vec<Value>,
99    },
100    /// Image content.
101    Image {
102        /// Image location or inline payload.
103        source: ImageSource,
104    },
105    /// Audio content.
106    Audio {
107        /// Audio location or inline payload.
108        source: MediaSource,
109    },
110    /// Video content.
111    Video {
112        /// Video location or inline payload.
113        source: MediaSource,
114    },
115    /// File content.
116    File {
117        /// File identifier or inline payload.
118        source: FileSource,
119    },
120    /// Tool invocation requested by the assistant.
121    ToolCall(ToolCall),
122    /// Result of an earlier tool invocation.
123    ToolResult(ToolResult),
124    /// MCP activity executed by the backend, rather than by the calling agent.
125    Mcp(McpContent),
126    /// Provider refusal content.
127    Refusal {
128        /// Human-readable refusal text.
129        text: String,
130    },
131    /// Provider block that has no normalized representation.
132    Unknown {
133        /// Wire format that supplied the block.
134        provider: FormatId,
135        /// Exact provider block.
136        raw: Value,
137    },
138}
139
140/// Image payload forms supported by the conversation model.
141#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
142#[serde(tag = "type", content = "data", rename_all = "snake_case")]
143pub enum ImageSource {
144    /// Image fetched from a URL.
145    Url {
146        /// Image URL.
147        url: String,
148        /// Optional provider-specific image detail level.
149        detail: Option<String>,
150    },
151    /// Base64-encoded image bytes.
152    Base64 {
153        /// MIME type, when supplied.
154        media_type: Option<String>,
155        /// Base64-encoded bytes.
156        data: String,
157    },
158    /// Provider image source with no normalized representation.
159    Raw(Value),
160}
161
162/// File payload forms supported by the conversation model.
163#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
164#[serde(tag = "type", content = "data", rename_all = "snake_case")]
165pub enum FileSource {
166    /// Provider-managed file identifier.
167    FileId(String),
168    /// Inline file payload.
169    FileData {
170        /// Encoded or textual file data as supplied by the provider format.
171        data: String,
172        /// Original filename, when supplied.
173        filename: Option<String>,
174    },
175    /// Provider file source with no normalized representation.
176    Raw(Value),
177}
178
179/// Audio and video payload forms supported by the conversation model.
180#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
181#[serde(tag = "type", content = "data", rename_all = "snake_case")]
182pub enum MediaSource {
183    /// Media fetched from a URL.
184    Url {
185        /// Media URL.
186        url: String,
187        /// MIME type, when supplied.
188        media_type: Option<String>,
189    },
190    /// Base64-encoded media bytes.
191    Base64 {
192        /// MIME type, when supplied.
193        media_type: Option<String>,
194        /// Base64-encoded bytes.
195        data: String,
196    },
197    /// Provider media source with no normalized representation.
198    Raw(Value),
199}
200
201/// Normalized assistant tool call.
202#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
203pub struct ToolCall {
204    /// Provider tool-call identifier used to pair the result.
205    pub id: String,
206    /// Tool name.
207    pub name: String,
208    /// Parsed tool arguments.
209    pub arguments: Value,
210}
211
212/// Normalized tool result message content.
213#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
214pub struct ToolResult {
215    /// Identifier of the [`ToolCall`] this result answers.
216    pub tool_call_id: String,
217    /// Ordered tool output content.
218    pub content: Vec<ContentBlock>,
219    /// Whether tool execution failed, when the provider reports it.
220    pub is_error: Option<bool>,
221}
222
223/// Normalized tool definition.
224#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
225pub struct ToolDefinition {
226    /// Tool name exposed to the model.
227    pub name: String,
228    /// Human-readable tool description.
229    pub description: Option<String>,
230    /// JSON Schema describing accepted arguments.
231    pub parameters: Value,
232    /// Whether the provider should enforce the schema strictly.
233    pub strict: Option<bool>,
234}
235
236/// A tool available to the model, in catalog order.
237#[derive(Clone, PartialEq, Serialize)]
238#[serde(tag = "type", rename_all = "snake_case")]
239pub enum Tool {
240    /// A remote MCP server and the tools it exposes.
241    Mcp(McpToolset),
242    /// A definition without a supported neutral representation.
243    Provider {
244        /// Source wire format; this definition is only replayed in that format.
245        provider: FormatId,
246        /// Complete provider definition.
247        raw: Value,
248    },
249    /// An agent-executed function. Its JSON retains the existing flat definition shape.
250    #[serde(untagged)]
251    Function(ToolDefinition),
252}
253
254impl<'de> Deserialize<'de> for Tool {
255    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
256    where
257        D: serde::Deserializer<'de>,
258    {
259        // Keep these variants and the conversion below in sync with Tool.
260        #[derive(Deserialize)]
261        #[serde(tag = "type", rename_all = "snake_case")]
262        enum TaggedTool {
263            Mcp(McpToolset),
264            Provider { provider: FormatId, raw: Value },
265            Function(ToolDefinition),
266        }
267
268        let value = Value::deserialize(deserializer)?;
269        // Only definitions without a type use the legacy function shape. A malformed
270        // tagged definition must fail instead of falling back to a function.
271        if value.get("type").is_none() {
272            return serde_json::from_value(value)
273                .map(Self::Function)
274                .map_err(serde::de::Error::custom);
275        }
276        let tool = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
277        Ok(match tool {
278            TaggedTool::Mcp(mcp) => Self::Mcp(mcp),
279            TaggedTool::Provider { provider, raw } => Self::Provider { provider, raw },
280            TaggedTool::Function(function) => Self::Function(function),
281        })
282    }
283}
284
285impl fmt::Debug for Tool {
286    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287        match self {
288            Self::Function(definition) => f.debug_tuple("Function").field(definition).finish(),
289            Self::Mcp(mcp) => f.debug_tuple("Mcp").field(mcp).finish(),
290            // Raw provider definitions can contain credentials without normalized fields.
291            Self::Provider { provider, .. } => f
292                .debug_struct("Provider")
293                .field("provider", provider)
294                .finish_non_exhaustive(),
295        }
296    }
297}
298
299impl From<ToolDefinition> for Tool {
300    fn from(definition: ToolDefinition) -> Self {
301        Self::Function(definition)
302    }
303}
304
305impl Tool {
306    /// Returns the function definition, if this is an agent-executed function.
307    pub fn function(&self) -> Option<&ToolDefinition> {
308        match self {
309            Self::Function(definition) => Some(definition),
310            _ => None,
311        }
312    }
313}
314
315/// Configuration for a URL-based remote MCP toolset.
316///
317/// This type covers the supported neutral settings. Definitions with additional settings,
318/// such as Responses headers, server descriptions, or conditional approval rules, belong
319/// in [`Tool::Provider`] with the complete original definition.
320#[derive(Clone, PartialEq, Serialize, Deserialize)]
321pub struct McpToolset {
322    /// Server identity used by MCP calls.
323    pub name: String,
324    /// Remote MCP endpoint.
325    pub url: String,
326    /// Credential sent to the MCP server, when supplied.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub authorization_token: Option<String>,
329    /// Defaults for tools without a per-tool setting in `configs`.
330    #[serde(default, skip_serializing_if = "McpToolConfig::is_empty")]
331    pub default_config: McpToolConfig,
332    /// Per-tool settings, keyed by tool name. Each omitted setting inherits
333    /// from `default_config`, then falls back to the provider's default.
334    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
335    pub configs: BTreeMap<String, McpToolConfig>,
336    /// Whether all calls require approval (`true`) or none do (`false`).
337    /// Absence retains provider defaults. Definitions with conditional approval rules
338    /// are stored in full as [`Tool::Provider`], rather than as this type.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub require_approval: Option<bool>,
341}
342
343impl fmt::Debug for McpToolset {
344    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345        let Self {
346            name,
347            url: _,
348            authorization_token,
349            default_config,
350            configs,
351            require_approval,
352        } = self;
353        f.debug_struct("McpToolset")
354            .field("name", name)
355            // Credentials can appear in any part of the URL, including its path.
356            .field("url", &"[REDACTED]")
357            .field(
358                "authorization_token",
359                &authorization_token.as_ref().map(|_| "[REDACTED]"),
360            )
361            .field("default_config", default_config)
362            .field("configs", configs)
363            .field("require_approval", require_approval)
364            .finish()
365    }
366}
367
368/// Settings for an individual MCP tool or the defaults for a toolset.
369#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
370pub struct McpToolConfig {
371    /// Whether the tool is available for the model to call.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub enabled: Option<bool>,
374    /// Whether the tool definition is loaded through tool search instead of initially.
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub defer_loading: Option<bool>,
377}
378
379impl McpToolConfig {
380    fn is_empty(&self) -> bool {
381        self.enabled.is_none() && self.defer_loading.is_none()
382    }
383}
384
385/// A tool discovered on an MCP server.
386#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
387pub struct McpListedTool {
388    /// Tool name as exposed by the server.
389    pub name: String,
390    /// Human-readable tool description, when supplied.
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub description: Option<String>,
393    /// JSON Schema describing accepted arguments.
394    pub input_schema: Value,
395    /// Additional tool metadata reported by the server.
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub annotations: Option<Value>,
398}
399
400/// MCP discovery and execution history. Results reference the matching call's server identity.
401#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
402#[serde(tag = "kind", rename_all = "snake_case")]
403pub enum McpContent {
404    /// Tools discovered on a server at this point in the conversation.
405    Listing {
406        /// Identity of the MCP server.
407        server_name: String,
408        /// Listing identifier, when supplied by the provider.
409        #[serde(default, skip_serializing_if = "Option::is_none")]
410        id: Option<String>,
411        /// Discovered tools in the order reported by the server.
412        tools: Vec<McpListedTool>,
413        /// Discovery failure, when reported. An empty tool list alone is not a failure.
414        #[serde(default, skip_serializing_if = "Option::is_none")]
415        error: Option<String>,
416    },
417    /// A call executed by the backend's MCP client.
418    Call {
419        /// Identity of the MCP server.
420        server_name: String,
421        /// Ordinary call ID, name, and arguments.
422        call: ToolCall,
423    },
424    /// Output from a backend-executed MCP call.
425    Result(ToolResult),
426}
427
428/// Normalized tool choice policy.
429#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
430#[serde(tag = "type", content = "data", rename_all = "snake_case")]
431pub enum ToolChoice {
432    /// Let the model decide whether to call a tool.
433    Auto,
434    /// Require at least one tool call.
435    Required,
436    /// Disallow tool calls.
437    None,
438    /// Require a specific tool.
439    Tool {
440        /// Required tool name.
441        name: String,
442    },
443    /// Provider tool-choice value with no normalized representation.
444    Raw(Value),
445}
446
447/// Provider sampling parameters with common cross-provider names.
448#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
449pub struct SamplingParams {
450    /// Sampling temperature.
451    pub temperature: Option<f64>,
452    /// Nucleus-sampling probability mass.
453    pub top_p: Option<f64>,
454    /// Maximum number of candidate tokens considered at each step.
455    pub top_k: Option<i64>,
456}
457
458/// Output budget and structured-output options.
459#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
460pub struct OutputParams {
461    /// Maximum tokens the model may generate.
462    pub max_output_tokens: Option<u64>,
463    /// Provider-neutral or provider-specific structured-output configuration.
464    pub response_format: Option<Value>,
465    /// Whether the provider must enforce the response schema, overriding native
466    /// flags when set and retaining native behavior when `None`.
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub is_schema_enforced: Option<bool>,
469}
470
471/// Provider reasoning controls preserved by translation.
472#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
473pub struct ReasoningParams {
474    /// Requested reasoning effort or level.
475    pub effort: Option<String>,
476    /// Provider reasoning controls without a normalized field.
477    pub raw: Option<Value>,
478}
479
480/// Provider-specific fields that do not have first-class conversation fields.
481#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
482#[serde(default)]
483pub struct ProviderExtensions {
484    /// Provider fields keyed by their original wire names.
485    ///
486    /// Codecs use these fields when translating to another format. Exact
487    /// same-format replay is controlled separately by [`PreservationMetadata`].
488    pub fields: Map<String, Value>,
489}
490
491// Extension values can include credentials or embedded original request bodies.
492impl fmt::Debug for ProviderExtensions {
493    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
494        f.debug_struct("ProviderExtensions")
495            .field("field_count", &self.fields.len())
496            .finish_non_exhaustive()
497    }
498}
499
500/// Exact source payloads retained for lossless same-format round trips.
501///
502/// Translation's default preservation policy prefers a stored same-format body
503/// over reconstructing one from normalized fields. A caller that mutates the IR
504/// must clear the corresponding entry or use a policy with preservation disabled
505/// when those mutations must be encoded.
506#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
507#[serde(default)]
508pub struct PreservationMetadata {
509    /// Original request bodies keyed by source format.
510    pub requests: BTreeMap<FormatId, Value>,
511    /// Original response bodies keyed by source format.
512    pub responses: BTreeMap<FormatId, Value>,
513}
514
515// Original payloads can contain credentials even when normalized fields redact them.
516impl fmt::Debug for PreservationMetadata {
517    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
518        f.debug_struct("PreservationMetadata")
519            .field("request_formats", &self.requests.keys())
520            .field("response_formats", &self.responses.keys())
521            .finish_non_exhaustive()
522    }
523}
524
525/// Normalized request representation shared by Switchyard components.
526#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
527#[serde(default)]
528pub struct LlmRequest {
529    /// Model currently addressed by the request.
530    ///
531    /// This initially contains the name supplied by the inbound client. A routing host may
532    /// replace it with the selected target before serving the request.
533    pub model: Option<String>,
534    /// System and developer instructions separated from conversation turns.
535    pub instructions: Vec<InstructionBlock>,
536    /// Ordered conversation messages.
537    pub messages: Vec<Message>,
538    /// Tools available to the model.
539    pub tools: Vec<Tool>,
540    /// Policy controlling model tool selection.
541    pub tool_choice: Option<ToolChoice>,
542    /// Common sampling controls.
543    pub sampling: SamplingParams,
544    /// Output budget and shape controls.
545    pub output: OutputParams,
546    /// Reasoning controls.
547    pub reasoning: ReasoningParams,
548    /// Whether the caller requested a streamed response.
549    pub stream: bool,
550    /// Provider fields without first-class normalized equivalents.
551    pub extensions: ProviderExtensions,
552    /// Exact provider bodies used by codecs for lossless same-format round trips.
553    /// This is separate from a host's optional
554    /// [`Request::raw_request`](crate::Request::raw_request).
555    pub preservation: PreservationMetadata,
556}
557
558/// Normalized token usage counts.
559#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
560pub struct Usage {
561    /// Non-cached input tokens. Provider codecs normalize aggregate OpenAI
562    /// input counts by subtracting the cache detail fields.
563    pub input_tokens: Option<u64>,
564    /// Cache-read and cache-creation token detail, when reported.
565    #[serde(flatten)]
566    pub cache: Option<Box<InputCacheUsage>>,
567    /// Generated output tokens, excluding reasoning detail when the provider reports it separately.
568    pub output_tokens: Option<u64>,
569    /// Provider-reported or codec-computed total token count.
570    ///
571    /// Codecs normalize OpenAI aggregate input counts before computing totals, so
572    /// this may equal non-cached input plus cache detail plus output.
573    pub total_tokens: Option<u64>,
574    /// Reasoning output tokens, when reported separately.
575    pub reasoning_tokens: Option<u64>,
576}
577
578/// Optional cache-token detail kept out of the common, cache-free usage allocation.
579#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
580pub struct InputCacheUsage {
581    /// Input tokens read from a provider cache.
582    pub cached_input_tokens: Option<u64>,
583    /// Input tokens written into a provider cache.
584    pub cache_creation_input_tokens: Option<u64>,
585}
586
587impl Usage {
588    /// Builds cache detail when at least one count is present.
589    pub fn cache_details(
590        cached_input_tokens: Option<u64>,
591        cache_creation_input_tokens: Option<u64>,
592    ) -> Option<Box<InputCacheUsage>> {
593        if cached_input_tokens.is_none() && cache_creation_input_tokens.is_none() {
594            return None;
595        }
596        Some(Box::new(InputCacheUsage {
597            cached_input_tokens,
598            cache_creation_input_tokens,
599        }))
600    }
601
602    /// Returns the cache-read input-token count.
603    pub fn cached_input_tokens(&self) -> Option<u64> {
604        self.cache
605            .as_ref()
606            .and_then(|cache| cache.cached_input_tokens)
607    }
608
609    /// Returns the cache-creation input-token count.
610    pub fn cache_creation_input_tokens(&self) -> Option<u64> {
611        self.cache
612            .as_ref()
613            .and_then(|cache| cache.cache_creation_input_tokens)
614    }
615
616    /// Sets the cache-read count, allocating cache detail when needed.
617    pub fn set_cached_input_tokens(&mut self, value: u64) {
618        self.cache
619            .get_or_insert_with(|| Box::new(InputCacheUsage::default()))
620            .cached_input_tokens = Some(value);
621    }
622
623    /// Sets the cache-creation count, allocating cache detail when needed.
624    pub fn set_cache_creation_input_tokens(&mut self, value: u64) {
625        self.cache
626            .get_or_insert_with(|| Box::new(InputCacheUsage::default()))
627            .cache_creation_input_tokens = Some(value);
628    }
629}
630
631/// Normalized reason a model stopped producing output.
632#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
633#[serde(rename_all = "snake_case")]
634pub enum StopReason {
635    /// Model completed its turn normally.
636    EndTurn,
637    /// Model reached the configured output limit.
638    MaxTokens,
639    /// Model stopped to request a tool call.
640    ToolUse,
641    /// Provider safety or content filtering stopped generation.
642    ContentFilter,
643    /// Generation terminated because of an error.
644    Error,
645    /// Provider stop reason with no normalized equivalent.
646    Unknown,
647}
648
649/// A source URL cited by generated text.
650#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
651pub struct UrlCitation {
652    /// Start character offset in the concatenated text and refusal blocks.
653    pub start_index: usize,
654    /// Exclusive end character offset in the same text.
655    pub end_index: usize,
656    /// Source URL.
657    pub url: String,
658    /// Source title.
659    pub title: String,
660}
661
662/// One assistant output item in a normalized response.
663#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
664pub struct ResponseOutput {
665    /// Actor that produced the output, normally [`Role::Assistant`].
666    pub role: Role,
667    /// Ordered output content.
668    pub content: Vec<ContentBlock>,
669    /// URL citations in the concatenated text and refusal blocks.
670    #[serde(default, skip_serializing_if = "Vec::is_empty")]
671    pub url_citations: Vec<UrlCitation>,
672    /// Why this output stopped, when known.
673    pub stop_reason: Option<StopReason>,
674}
675
676/// Normalized, fully-buffered response — the aggregate of a completed generation.
677/// This is the terminal form of a streamed [`LlmResponse`](crate::LlmResponse).
678#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
679#[serde(default)]
680pub struct AggLlmResponse {
681    /// Provider response identifier.
682    pub id: Option<String>,
683    /// Model reported by the provider.
684    pub model: Option<String>,
685    /// Ordered response output items.
686    pub outputs: Vec<ResponseOutput>,
687    /// Normalized token usage.
688    pub usage: Usage,
689    /// Provider response fields without normalized equivalents.
690    pub extensions: ProviderExtensions,
691    /// Exact provider bodies retained for lossless round trips.
692    pub preservation: PreservationMetadata,
693}
694
695impl AggLlmResponse {
696    /// Returns the first output item when a response has any output.
697    pub fn first_output(&self) -> Option<&ResponseOutput> {
698        self.outputs.first()
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    use serde_json::json;
705
706    use super::*;
707
708    #[test]
709    fn serde_uses_python_friendly_dictionary_shapes() -> Result<(), serde_json::Error> {
710        let request: LlmRequest = serde_json::from_value(json!({
711            "model": "auto",
712            "messages": [{
713                "role": "user",
714                "content": [{"type": "text", "text": "hello"}]
715            }]
716        }))?;
717        assert_eq!(request.messages[0], Message::text(Role::User, "hello"));
718
719        let tool_call = ContentBlock::ToolCall(ToolCall {
720            id: "call-1".to_string(),
721            name: "lookup".to_string(),
722            arguments: json!({"query": "rust"}),
723        });
724        assert_eq!(
725            serde_json::to_value(tool_call)?,
726            json!({
727                "type": "tool_call",
728                "id": "call-1",
729                "name": "lookup",
730                "arguments": {"query": "rust"}
731            })
732        );
733        Ok(())
734    }
735
736    #[test]
737    fn tool_deserialization_rejects_invalid_tagged_definitions() {
738        for definition in [
739            json!({"type": "mcp", "name": "inv", "parameters": {}}),
740            json!({"type": "provider", "provider": "x", "name": "n", "parameters": {}}),
741            json!({"type": "unknown", "name": "n", "parameters": {}}),
742            json!({"type": null, "name": "n", "parameters": {}}),
743        ] {
744            assert!(serde_json::from_value::<Tool>(definition).is_err());
745        }
746        let error = serde_json::from_value::<Tool>(json!({
747            "type": "mcp", "name": "inv", "url": "https://example.invalid/mcp",
748            "authorization_token": 5, "parameters": {}
749        }))
750        .expect_err("an invalid credential must not become a function")
751        .to_string();
752        assert!(error.contains("string"), "field error was lost: {error}");
753    }
754
755    #[test]
756    fn tool_catalog_preserves_function_json_and_backend_history() -> Result<(), serde_json::Error> {
757        let function = json!({"name": "lookup", "description": null, "parameters": {"type": "object"}, "strict": null});
758        let definitions = json!([
759            function,
760            {"type": "mcp", "name": "inventory", "url": "https://example.invalid/mcp", "require_approval": false},
761            {"type": "provider", "provider": "openai_responses", "raw": {"type": "mcp", "connector_id": "connector_example", "server_label": "example"}},
762            {
763                "type": "mcp", "name": "configured", "url": "https://example.invalid/mcp",
764                "default_config": {"enabled": false, "defer_loading": true},
765                "configs": {"lookup": {"enabled": true}, "search": {"defer_loading": false}}
766            }
767        ]);
768        let tools: Vec<Tool> = serde_json::from_value(definitions.clone())?;
769        assert!(matches!(&tools[0], Tool::Function(_)));
770        assert!(matches!(&tools[1], Tool::Mcp(_)));
771        assert!(matches!(&tools[2], Tool::Provider { .. }));
772        assert_eq!(serde_json::to_value(&tools)?, definitions);
773        let explicit_function: Tool = serde_json::from_value(json!({
774            "type": "function", "name": "lookup", "parameters": {"type": "object"}
775        }))?;
776        assert_eq!(explicit_function, tools[0]);
777
778        let history = json!([
779            {
780                "type": "mcp", "kind": "listing", "server_name": "inventory",
781                "id": "listing_1",
782                "tools": [{
783                    "name": "lookup", "description": "Look up stock",
784                    "input_schema": {"type": "object"},
785                    "annotations": {"readOnlyHint": true}
786                }]
787            },
788            {"type": "mcp", "kind": "listing", "server_name": "empty", "tools": []},
789            {
790                "type": "mcp", "kind": "listing", "server_name": "unavailable",
791                "tools": [], "error": "Server unavailable"
792            },
793            {
794                "type": "mcp", "kind": "call", "server_name": "inventory",
795                "call": {"id": "call_1", "name": "lookup", "arguments": {}}
796            },
797            {
798                "type": "mcp", "kind": "result", "tool_call_id": "call_1",
799                "content": [{"type": "text", "text": "in stock"}], "is_error": false
800            }
801        ]);
802        let content: Vec<ContentBlock> = serde_json::from_value(history.clone())?;
803        assert_eq!(serde_json::to_value(&content)?, history);
804        Ok(())
805    }
806
807    #[test]
808    fn mcp_credentials_are_omitted_from_debug() -> Result<(), serde_json::Error> {
809        let token = "synthetic-mcp-debug-secret";
810        let url_secret = "synthetic-mcp-url-secret";
811        let url = format!("https://example.invalid/mcp?api_key={url_secret}");
812        let mcp: McpToolset = serde_json::from_value(json!({
813            "name": "inventory", "url": url,
814            "authorization_token": token
815        }))?;
816        let raw = json!({"mcp_servers": [{"authorization_token": token}]});
817        let mut request = LlmRequest {
818            tools: vec![
819                Tool::Mcp(mcp.clone()),
820                Tool::Provider {
821                    provider: "openai_responses".into(),
822                    raw: json!({"type": "mcp", "authorization": token}),
823                },
824            ],
825            ..LlmRequest::default()
826        };
827        request
828            .preservation
829            .requests
830            .insert("anthropic_messages".into(), raw.clone());
831        request.extensions.fields.insert("metadata".into(), raw);
832        let serialized = serde_json::to_value(&request)?;
833        for debug in [
834            format!("{mcp:?}"),
835            format!("{mcp:#?}"),
836            format!("{:?}", request.tools),
837            format!("{:?}", request.extensions),
838            format!("{:?}", request.preservation),
839            format!("{request:?}"),
840            format!("{request:#?}"),
841        ] {
842            assert!(!debug.contains(token), "Debug exposed an MCP credential");
843            assert!(
844                !debug.contains(url_secret),
845                "Debug exposed an MCP URL secret"
846            );
847        }
848        assert_eq!(serde_json::to_value(&request)?, serialized);
849        assert_eq!(serialized["tools"][0]["url"], url);
850        assert_eq!(serialized["tools"][0]["authorization_token"], token);
851        assert_eq!(serialized["tools"][1]["raw"]["authorization"], token);
852        Ok(())
853    }
854}