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