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