1use std::collections::BTreeMap;
11use std::fmt;
12
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16use crate::format::FormatId;
17
18#[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,
25 Developer,
27 User,
29 Assistant,
31 Tool,
33}
34
35#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
37pub struct InstructionBlock {
38 pub role: Role,
40 pub content: Vec<ContentBlock>,
42}
43
44#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
46pub struct Message {
47 pub role: Role,
49 pub content: Vec<ContentBlock>,
51}
52
53impl Message {
54 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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
83#[serde(tag = "type", rename_all = "snake_case")]
84pub enum ContentBlock {
85 Text {
87 text: String,
89 },
90 Reasoning {
92 text: String,
94 signature: Option<String>,
96 #[serde(default, skip_serializing_if = "Vec::is_empty")]
99 details: Vec<Value>,
100 },
101 Image {
103 source: ImageSource,
105 },
106 Audio {
108 source: MediaSource,
110 },
111 Video {
113 source: MediaSource,
115 },
116 File {
118 source: FileSource,
120 },
121 ToolCall(ToolCall),
123 ToolResult(ToolResult),
125 Mcp(McpContent),
127 Refusal {
129 text: String,
131 },
132 Unknown {
134 provider: FormatId,
136 raw: Value,
138 },
139}
140
141#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
143#[serde(tag = "type", content = "data", rename_all = "snake_case")]
144pub enum ImageSource {
145 Url {
147 url: String,
149 detail: Option<String>,
151 },
152 Base64 {
154 media_type: Option<String>,
156 data: String,
158 },
159 Raw(Value),
161}
162
163#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
165#[serde(tag = "type", content = "data", rename_all = "snake_case")]
166pub enum FileSource {
167 FileId(String),
169 FileData {
171 data: String,
173 filename: Option<String>,
175 },
176 Raw(Value),
178}
179
180#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
182#[serde(tag = "type", content = "data", rename_all = "snake_case")]
183pub enum MediaSource {
184 Url {
186 url: String,
188 media_type: Option<String>,
190 },
191 Base64 {
193 media_type: Option<String>,
195 data: String,
197 },
198 Raw(Value),
200}
201
202#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
204pub struct ToolCall {
205 pub id: String,
207 pub name: String,
209 pub arguments: Value,
211}
212
213#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
215pub struct ToolResult {
216 pub tool_call_id: String,
218 pub content: Vec<ContentBlock>,
220 pub is_error: Option<bool>,
222}
223
224#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
226pub struct ToolDefinition {
227 pub name: String,
229 pub description: Option<String>,
231 pub parameters: Value,
233 pub strict: Option<bool>,
235}
236
237#[derive(Clone, PartialEq, Serialize)]
239#[serde(tag = "type", rename_all = "snake_case")]
240pub enum Tool {
241 Mcp(McpToolset),
243 Provider {
245 provider: FormatId,
247 raw: Value,
249 },
250 #[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 #[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 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 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 pub fn function(&self) -> Option<&ToolDefinition> {
309 match self {
310 Self::Function(definition) => Some(definition),
311 _ => None,
312 }
313 }
314}
315
316#[derive(Clone, PartialEq, Serialize, Deserialize)]
322pub struct McpToolset {
323 pub name: String,
325 pub url: String,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub authorization_token: Option<String>,
330 #[serde(default, skip_serializing_if = "McpToolConfig::is_empty")]
332 pub default_config: McpToolConfig,
333 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
336 pub configs: BTreeMap<String, McpToolConfig>,
337 #[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 .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#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
371pub struct McpToolConfig {
372 #[serde(default, skip_serializing_if = "Option::is_none")]
374 pub enabled: Option<bool>,
375 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
388pub struct McpListedTool {
389 pub name: String,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub description: Option<String>,
394 pub input_schema: Value,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub annotations: Option<Value>,
399}
400
401#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
403#[serde(tag = "kind", rename_all = "snake_case")]
404pub enum McpContent {
405 Listing {
407 server_name: String,
409 #[serde(default, skip_serializing_if = "Option::is_none")]
411 id: Option<String>,
412 tools: Vec<McpListedTool>,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
416 error: Option<String>,
417 },
418 Call {
420 server_name: String,
422 call: ToolCall,
424 },
425 Result(ToolResult),
427}
428
429#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
431#[serde(tag = "type", content = "data", rename_all = "snake_case")]
432pub enum ToolChoice {
433 Auto,
435 Required,
437 None,
439 Tool {
441 name: String,
443 },
444 Raw(Value),
446}
447
448#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
450pub struct SamplingParams {
451 pub temperature: Option<f64>,
453 pub top_p: Option<f64>,
455 pub top_k: Option<i64>,
457}
458
459#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
461pub struct OutputParams {
462 pub max_output_tokens: Option<u64>,
464 pub response_format: Option<Value>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub is_schema_enforced: Option<bool>,
470}
471
472#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
474pub struct ReasoningParams {
475 pub effort: Option<String>,
477 pub raw: Option<Value>,
479}
480
481#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
483#[serde(default)]
484pub struct ProviderExtensions {
485 pub fields: Map<String, Value>,
490}
491
492impl 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#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
508#[serde(default)]
509pub struct PreservationMetadata {
510 pub requests: BTreeMap<FormatId, Value>,
512 pub responses: BTreeMap<FormatId, Value>,
514}
515
516impl 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#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
528#[serde(default)]
529pub struct LlmRequest {
530 pub model: Option<String>,
535 pub instructions: Vec<InstructionBlock>,
537 pub messages: Vec<Message>,
539 pub tools: Vec<Tool>,
541 pub tool_choice: Option<ToolChoice>,
543 pub sampling: SamplingParams,
545 pub output: OutputParams,
547 pub reasoning: ReasoningParams,
549 pub stream: bool,
551 pub extensions: ProviderExtensions,
553 pub preservation: PreservationMetadata,
557}
558
559#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
561pub struct Usage {
562 pub input_tokens: Option<u64>,
565 #[serde(flatten)]
567 pub cache: Option<Box<InputCacheUsage>>,
568 pub output_tokens: Option<u64>,
570 pub total_tokens: Option<u64>,
575 pub reasoning_tokens: Option<u64>,
577}
578
579#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
581pub struct InputCacheUsage {
582 pub cached_input_tokens: Option<u64>,
584 pub cache_creation_input_tokens: Option<u64>,
586}
587
588impl Usage {
589 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 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 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 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
634#[serde(rename_all = "snake_case")]
635pub enum StopReason {
636 EndTurn,
638 MaxTokens,
640 ToolUse,
642 ContentFilter,
644 Error,
646 Unknown,
648}
649
650#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
652pub struct UrlCitation {
653 pub start_index: usize,
655 pub end_index: usize,
657 pub url: String,
659 pub title: String,
661}
662
663#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
665pub struct ResponseOutput {
666 pub role: Role,
668 pub content: Vec<ContentBlock>,
670 #[serde(default, skip_serializing_if = "Vec::is_empty")]
672 pub url_citations: Vec<UrlCitation>,
673 pub stop_reason: Option<StopReason>,
675}
676
677#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
680#[serde(default)]
681pub struct AggLlmResponse {
682 pub id: Option<String>,
684 pub model: Option<String>,
686 pub outputs: Vec<ResponseOutput>,
688 pub usage: Usage,
690 pub extensions: ProviderExtensions,
692 pub preservation: PreservationMetadata,
694}
695
696impl AggLlmResponse {
697 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}