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    /// The upstream provider reports this monetary charge in USD.
563    #[serde(default, skip_serializing_if = "Option::is_none")]
564    pub provider_cost: Option<f64>,
565    /// Non-cached input tokens. Provider codecs normalize aggregate OpenAI
566    /// input counts by subtracting the cache detail fields.
567    pub input_tokens: Option<u64>,
568    /// Cache-read and cache-creation token detail, when reported.
569    #[serde(flatten)]
570    pub cache: Option<Box<InputCacheUsage>>,
571    /// The output count includes reasoning tokens when the provider counts them.
572    pub output_tokens: Option<u64>,
573    /// Provider-reported or codec-computed total token count.
574    ///
575    /// Codecs normalize OpenAI aggregate input counts before computing totals, so
576    /// this may equal non-cached input plus cache detail plus output.
577    pub total_tokens: Option<u64>,
578    /// Reasoning output tokens, when reported separately.
579    pub reasoning_tokens: Option<u64>,
580}
581
582/// Optional cache-token detail kept out of the common, cache-free usage allocation.
583#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
584pub struct InputCacheUsage {
585    /// The provider reports these cache-write tokens at the one-hour rate.
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    pub cache_creation_1h_input_tokens: Option<u64>,
588    /// Input tokens read from a provider cache.
589    pub cached_input_tokens: Option<u64>,
590    /// Input tokens written into a provider cache.
591    pub cache_creation_input_tokens: Option<u64>,
592}
593
594impl Usage {
595    /// Builds cache detail when at least one count is present.
596    pub fn cache_details(
597        cached_input_tokens: Option<u64>,
598        cache_creation_input_tokens: Option<u64>,
599    ) -> Option<Box<InputCacheUsage>> {
600        if cached_input_tokens.is_none() && cache_creation_input_tokens.is_none() {
601            return None;
602        }
603        Some(Box::new(InputCacheUsage {
604            cache_creation_1h_input_tokens: None,
605            cached_input_tokens,
606            cache_creation_input_tokens,
607        }))
608    }
609
610    /// Returns the cache-read input-token count.
611    pub fn cached_input_tokens(&self) -> Option<u64> {
612        self.cache
613            .as_ref()
614            .and_then(|cache| cache.cached_input_tokens)
615    }
616
617    /// Returns the cache-creation input-token count.
618    pub fn cache_creation_input_tokens(&self) -> Option<u64> {
619        self.cache
620            .as_ref()
621            .and_then(|cache| cache.cache_creation_input_tokens)
622    }
623
624    /// Sets the cache-read count, allocating cache detail when needed.
625    pub fn set_cached_input_tokens(&mut self, value: u64) {
626        self.cache
627            .get_or_insert_with(|| Box::new(InputCacheUsage::default()))
628            .cached_input_tokens = Some(value);
629    }
630
631    /// Sets the cache-creation count, allocating cache detail when needed.
632    pub fn set_cache_creation_input_tokens(&mut self, value: u64) {
633        self.cache
634            .get_or_insert_with(|| Box::new(InputCacheUsage::default()))
635            .cache_creation_input_tokens = Some(value);
636    }
637}
638
639/// Normalized reason a model stopped producing output.
640#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
641#[serde(rename_all = "snake_case")]
642pub enum StopReason {
643    /// Model completed its turn normally.
644    EndTurn,
645    /// Model reached the configured output limit.
646    MaxTokens,
647    /// Model stopped to request a tool call.
648    ToolUse,
649    /// Provider safety or content filtering stopped generation.
650    ContentFilter,
651    /// Generation terminated because of an error.
652    Error,
653    /// Provider stop reason with no normalized equivalent.
654    Unknown,
655}
656
657/// A source URL cited by generated text.
658#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
659pub struct UrlCitation {
660    /// Start character offset in the concatenated text and refusal blocks.
661    pub start_index: usize,
662    /// Exclusive end character offset in the same text.
663    pub end_index: usize,
664    /// Source URL.
665    pub url: String,
666    /// Source title.
667    pub title: String,
668}
669
670/// One assistant output item in a normalized response.
671#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
672pub struct ResponseOutput {
673    /// Actor that produced the output, normally [`Role::Assistant`].
674    pub role: Role,
675    /// Ordered output content.
676    pub content: Vec<ContentBlock>,
677    /// URL citations in the concatenated text and refusal blocks.
678    #[serde(default, skip_serializing_if = "Vec::is_empty")]
679    pub url_citations: Vec<UrlCitation>,
680    /// Why this output stopped, when known.
681    pub stop_reason: Option<StopReason>,
682}
683
684/// Normalized, fully-buffered response — the aggregate of a completed generation.
685/// This is the terminal form of a streamed [`LlmResponse`](crate::LlmResponse).
686#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
687#[serde(default)]
688pub struct AggLlmResponse {
689    /// Provider response identifier.
690    pub id: Option<String>,
691    /// Model reported by the provider.
692    pub model: Option<String>,
693    /// Ordered response output items.
694    pub outputs: Vec<ResponseOutput>,
695    /// Normalized token usage.
696    pub usage: Usage,
697    /// Provider response fields without normalized equivalents.
698    pub extensions: ProviderExtensions,
699    /// Exact provider bodies retained for lossless round trips.
700    pub preservation: PreservationMetadata,
701}
702
703impl AggLlmResponse {
704    /// Returns the first output item when a response has any output.
705    pub fn first_output(&self) -> Option<&ResponseOutput> {
706        self.outputs.first()
707    }
708}
709
710#[cfg(test)]
711mod tests {
712    use serde_json::json;
713
714    use super::*;
715
716    #[test]
717    fn serde_uses_python_friendly_dictionary_shapes() -> Result<(), serde_json::Error> {
718        let request: LlmRequest = serde_json::from_value(json!({
719            "model": "auto",
720            "messages": [{
721                "role": "user",
722                "content": [{"type": "text", "text": "hello"}]
723            }]
724        }))?;
725        assert_eq!(request.messages[0], Message::text(Role::User, "hello"));
726
727        let tool_call = ContentBlock::ToolCall(ToolCall {
728            id: "call-1".to_string(),
729            name: "lookup".to_string(),
730            arguments: json!({"query": "rust"}),
731        });
732        assert_eq!(
733            serde_json::to_value(tool_call)?,
734            json!({
735                "type": "tool_call",
736                "id": "call-1",
737                "name": "lookup",
738                "arguments": {"query": "rust"}
739            })
740        );
741        Ok(())
742    }
743
744    #[test]
745    fn tool_deserialization_rejects_invalid_tagged_definitions() {
746        for definition in [
747            json!({"type": "mcp", "name": "inv", "parameters": {}}),
748            json!({"type": "provider", "provider": "x", "name": "n", "parameters": {}}),
749            json!({"type": "unknown", "name": "n", "parameters": {}}),
750            json!({"type": null, "name": "n", "parameters": {}}),
751        ] {
752            assert!(serde_json::from_value::<Tool>(definition).is_err());
753        }
754        let error = serde_json::from_value::<Tool>(json!({
755            "type": "mcp", "name": "inv", "url": "https://example.invalid/mcp",
756            "authorization_token": 5, "parameters": {}
757        }))
758        .expect_err("an invalid credential must not become a function")
759        .to_string();
760        assert!(error.contains("string"), "field error was lost: {error}");
761    }
762
763    #[test]
764    fn tool_catalog_preserves_function_json_and_backend_history() -> Result<(), serde_json::Error> {
765        let function = json!({"name": "lookup", "description": null, "parameters": {"type": "object"}, "strict": null});
766        let definitions = json!([
767            function,
768            {"type": "mcp", "name": "inventory", "url": "https://example.invalid/mcp", "require_approval": false},
769            {"type": "provider", "provider": "openai_responses", "raw": {"type": "mcp", "connector_id": "connector_example", "server_label": "example"}},
770            {
771                "type": "mcp", "name": "configured", "url": "https://example.invalid/mcp",
772                "default_config": {"enabled": false, "defer_loading": true},
773                "configs": {"lookup": {"enabled": true}, "search": {"defer_loading": false}}
774            }
775        ]);
776        let tools: Vec<Tool> = serde_json::from_value(definitions.clone())?;
777        assert!(matches!(&tools[0], Tool::Function(_)));
778        assert!(matches!(&tools[1], Tool::Mcp(_)));
779        assert!(matches!(&tools[2], Tool::Provider { .. }));
780        assert_eq!(serde_json::to_value(&tools)?, definitions);
781        let explicit_function: Tool = serde_json::from_value(json!({
782            "type": "function", "name": "lookup", "parameters": {"type": "object"}
783        }))?;
784        assert_eq!(explicit_function, tools[0]);
785
786        let history = json!([
787            {
788                "type": "mcp", "kind": "listing", "server_name": "inventory",
789                "id": "listing_1",
790                "tools": [{
791                    "name": "lookup", "description": "Look up stock",
792                    "input_schema": {"type": "object"},
793                    "annotations": {"readOnlyHint": true}
794                }]
795            },
796            {"type": "mcp", "kind": "listing", "server_name": "empty", "tools": []},
797            {
798                "type": "mcp", "kind": "listing", "server_name": "unavailable",
799                "tools": [], "error": "Server unavailable"
800            },
801            {
802                "type": "mcp", "kind": "call", "server_name": "inventory",
803                "call": {"id": "call_1", "name": "lookup", "arguments": {}}
804            },
805            {
806                "type": "mcp", "kind": "result", "tool_call_id": "call_1",
807                "content": [{"type": "text", "text": "in stock"}], "is_error": false
808            }
809        ]);
810        let content: Vec<ContentBlock> = serde_json::from_value(history.clone())?;
811        assert_eq!(serde_json::to_value(&content)?, history);
812        Ok(())
813    }
814
815    #[test]
816    fn mcp_credentials_are_omitted_from_debug() -> Result<(), serde_json::Error> {
817        let token = "synthetic-mcp-debug-secret";
818        let url_secret = "synthetic-mcp-url-secret";
819        let url = format!("https://example.invalid/mcp?api_key={url_secret}");
820        let mcp: McpToolset = serde_json::from_value(json!({
821            "name": "inventory", "url": url,
822            "authorization_token": token
823        }))?;
824        let raw = json!({"mcp_servers": [{"authorization_token": token}]});
825        let mut request = LlmRequest {
826            tools: vec![
827                Tool::Mcp(mcp.clone()),
828                Tool::Provider {
829                    provider: "openai_responses".into(),
830                    raw: json!({"type": "mcp", "authorization": token}),
831                },
832            ],
833            ..LlmRequest::default()
834        };
835        request
836            .preservation
837            .requests
838            .insert("anthropic_messages".into(), raw.clone());
839        request.extensions.fields.insert("metadata".into(), raw);
840        let serialized = serde_json::to_value(&request)?;
841        for debug in [
842            format!("{mcp:?}"),
843            format!("{mcp:#?}"),
844            format!("{:?}", request.tools),
845            format!("{:?}", request.extensions),
846            format!("{:?}", request.preservation),
847            format!("{request:?}"),
848            format!("{request:#?}"),
849        ] {
850            assert!(!debug.contains(token), "Debug exposed an MCP credential");
851            assert!(
852                !debug.contains(url_secret),
853                "Debug exposed an MCP URL secret"
854            );
855        }
856        assert_eq!(serde_json::to_value(&request)?, serialized);
857        assert_eq!(serialized["tools"][0]["url"], url);
858        assert_eq!(serialized["tools"][0]["authorization_token"], token);
859        assert_eq!(serialized["tools"][1]["raw"]["authorization"], token);
860        Ok(())
861    }
862}