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)]
20#[serde(rename_all = "snake_case")]
21pub enum Role {
22 System,
24 Developer,
26 User,
28 Assistant,
30 Tool,
32}
33
34#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
36pub struct InstructionBlock {
37 pub role: Role,
39 pub content: Vec<ContentBlock>,
41}
42
43#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
45pub struct Message {
46 pub role: Role,
48 pub content: Vec<ContentBlock>,
50}
51
52impl Message {
53 pub fn text(role: Role, text: impl Into<String>) -> Self {
55 Self {
56 role,
57 content: vec![ContentBlock::Text { text: text.into() }],
58 }
59 }
60
61 pub fn text_content(&self, separator: &str) -> Option<String> {
63 let parts = self
64 .content
65 .iter()
66 .filter_map(|block| match block {
67 ContentBlock::Text { text } => Some(text.as_str()),
68 ContentBlock::Refusal { text } => Some(text.as_str()),
69 _ => None,
70 })
71 .collect::<Vec<_>>();
72 if parts.is_empty() {
73 None
74 } else {
75 Some(parts.join(separator))
76 }
77 }
78}
79
80#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
82#[serde(tag = "type", rename_all = "snake_case")]
83pub enum ContentBlock {
84 Text {
86 text: String,
88 },
89 Reasoning {
91 text: String,
93 signature: Option<String>,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 details: Vec<Value>,
99 },
100 Image {
102 source: ImageSource,
104 },
105 Audio {
107 source: MediaSource,
109 },
110 Video {
112 source: MediaSource,
114 },
115 File {
117 source: FileSource,
119 },
120 ToolCall(ToolCall),
122 ToolResult(ToolResult),
124 Mcp(McpContent),
126 Refusal {
128 text: String,
130 },
131 Unknown {
133 provider: FormatId,
135 raw: Value,
137 },
138}
139
140#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
142#[serde(tag = "type", content = "data", rename_all = "snake_case")]
143pub enum ImageSource {
144 Url {
146 url: String,
148 detail: Option<String>,
150 },
151 Base64 {
153 media_type: Option<String>,
155 data: String,
157 },
158 Raw(Value),
160}
161
162#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
164#[serde(tag = "type", content = "data", rename_all = "snake_case")]
165pub enum FileSource {
166 FileId(String),
168 FileData {
170 data: String,
172 filename: Option<String>,
174 },
175 Raw(Value),
177}
178
179#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
181#[serde(tag = "type", content = "data", rename_all = "snake_case")]
182pub enum MediaSource {
183 Url {
185 url: String,
187 media_type: Option<String>,
189 },
190 Base64 {
192 media_type: Option<String>,
194 data: String,
196 },
197 Raw(Value),
199}
200
201#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
203pub struct ToolCall {
204 pub id: String,
206 pub name: String,
208 pub arguments: Value,
210}
211
212#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
214pub struct ToolResult {
215 pub tool_call_id: String,
217 pub content: Vec<ContentBlock>,
219 pub is_error: Option<bool>,
221}
222
223#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
225pub struct ToolDefinition {
226 pub name: String,
228 pub description: Option<String>,
230 pub parameters: Value,
232 pub strict: Option<bool>,
234}
235
236#[derive(Clone, PartialEq, Serialize)]
238#[serde(tag = "type", rename_all = "snake_case")]
239pub enum Tool {
240 Mcp(McpToolset),
242 Provider {
244 provider: FormatId,
246 raw: Value,
248 },
249 #[serde(untagged)]
251 Function(ToolDefinition),
252}
253
254impl<'de> Deserialize<'de> for Tool {
255 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
256 where
257 D: serde::Deserializer<'de>,
258 {
259 #[derive(Deserialize)]
261 #[serde(tag = "type", rename_all = "snake_case")]
262 enum TaggedTool {
263 Mcp(McpToolset),
264 Provider { provider: FormatId, raw: Value },
265 Function(ToolDefinition),
266 }
267
268 let value = Value::deserialize(deserializer)?;
269 if value.get("type").is_none() {
272 return serde_json::from_value(value)
273 .map(Self::Function)
274 .map_err(serde::de::Error::custom);
275 }
276 let tool = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
277 Ok(match tool {
278 TaggedTool::Mcp(mcp) => Self::Mcp(mcp),
279 TaggedTool::Provider { provider, raw } => Self::Provider { provider, raw },
280 TaggedTool::Function(function) => Self::Function(function),
281 })
282 }
283}
284
285impl fmt::Debug for Tool {
286 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287 match self {
288 Self::Function(definition) => f.debug_tuple("Function").field(definition).finish(),
289 Self::Mcp(mcp) => f.debug_tuple("Mcp").field(mcp).finish(),
290 Self::Provider { provider, .. } => f
292 .debug_struct("Provider")
293 .field("provider", provider)
294 .finish_non_exhaustive(),
295 }
296 }
297}
298
299impl From<ToolDefinition> for Tool {
300 fn from(definition: ToolDefinition) -> Self {
301 Self::Function(definition)
302 }
303}
304
305impl Tool {
306 pub fn function(&self) -> Option<&ToolDefinition> {
308 match self {
309 Self::Function(definition) => Some(definition),
310 _ => None,
311 }
312 }
313}
314
315#[derive(Clone, PartialEq, Serialize, Deserialize)]
321pub struct McpToolset {
322 pub name: String,
324 pub url: String,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub authorization_token: Option<String>,
329 #[serde(default, skip_serializing_if = "McpToolConfig::is_empty")]
331 pub default_config: McpToolConfig,
332 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
335 pub configs: BTreeMap<String, McpToolConfig>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub require_approval: Option<bool>,
341}
342
343impl fmt::Debug for McpToolset {
344 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345 let Self {
346 name,
347 url: _,
348 authorization_token,
349 default_config,
350 configs,
351 require_approval,
352 } = self;
353 f.debug_struct("McpToolset")
354 .field("name", name)
355 .field("url", &"[REDACTED]")
357 .field(
358 "authorization_token",
359 &authorization_token.as_ref().map(|_| "[REDACTED]"),
360 )
361 .field("default_config", default_config)
362 .field("configs", configs)
363 .field("require_approval", require_approval)
364 .finish()
365 }
366}
367
368#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
370pub struct McpToolConfig {
371 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub enabled: Option<bool>,
374 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub defer_loading: Option<bool>,
377}
378
379impl McpToolConfig {
380 fn is_empty(&self) -> bool {
381 self.enabled.is_none() && self.defer_loading.is_none()
382 }
383}
384
385#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
387pub struct McpListedTool {
388 pub name: String,
390 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub description: Option<String>,
393 pub input_schema: Value,
395 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub annotations: Option<Value>,
398}
399
400#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
402#[serde(tag = "kind", rename_all = "snake_case")]
403pub enum McpContent {
404 Listing {
406 server_name: String,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
410 id: Option<String>,
411 tools: Vec<McpListedTool>,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
415 error: Option<String>,
416 },
417 Call {
419 server_name: String,
421 call: ToolCall,
423 },
424 Result(ToolResult),
426}
427
428#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
430#[serde(tag = "type", content = "data", rename_all = "snake_case")]
431pub enum ToolChoice {
432 Auto,
434 Required,
436 None,
438 Tool {
440 name: String,
442 },
443 Raw(Value),
445}
446
447#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
449pub struct SamplingParams {
450 pub temperature: Option<f64>,
452 pub top_p: Option<f64>,
454 pub top_k: Option<i64>,
456}
457
458#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
460pub struct OutputParams {
461 pub max_output_tokens: Option<u64>,
463 pub response_format: Option<Value>,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub is_schema_enforced: Option<bool>,
469}
470
471#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
473pub struct ReasoningParams {
474 pub effort: Option<String>,
476 pub raw: Option<Value>,
478}
479
480#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
482#[serde(default)]
483pub struct ProviderExtensions {
484 pub fields: Map<String, Value>,
489}
490
491impl fmt::Debug for ProviderExtensions {
493 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
494 f.debug_struct("ProviderExtensions")
495 .field("field_count", &self.fields.len())
496 .finish_non_exhaustive()
497 }
498}
499
500#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
507#[serde(default)]
508pub struct PreservationMetadata {
509 pub requests: BTreeMap<FormatId, Value>,
511 pub responses: BTreeMap<FormatId, Value>,
513}
514
515impl fmt::Debug for PreservationMetadata {
517 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
518 f.debug_struct("PreservationMetadata")
519 .field("request_formats", &self.requests.keys())
520 .field("response_formats", &self.responses.keys())
521 .finish_non_exhaustive()
522 }
523}
524
525#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
527#[serde(default)]
528pub struct LlmRequest {
529 pub model: Option<String>,
534 pub instructions: Vec<InstructionBlock>,
536 pub messages: Vec<Message>,
538 pub tools: Vec<Tool>,
540 pub tool_choice: Option<ToolChoice>,
542 pub sampling: SamplingParams,
544 pub output: OutputParams,
546 pub reasoning: ReasoningParams,
548 pub stream: bool,
550 pub extensions: ProviderExtensions,
552 pub preservation: PreservationMetadata,
556}
557
558#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
560pub struct Usage {
561 pub input_tokens: Option<u64>,
564 #[serde(flatten)]
566 pub cache: Option<Box<InputCacheUsage>>,
567 pub output_tokens: Option<u64>,
569 pub total_tokens: Option<u64>,
574 pub reasoning_tokens: Option<u64>,
576}
577
578#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
580pub struct InputCacheUsage {
581 pub cached_input_tokens: Option<u64>,
583 pub cache_creation_input_tokens: Option<u64>,
585}
586
587impl Usage {
588 pub fn cache_details(
590 cached_input_tokens: Option<u64>,
591 cache_creation_input_tokens: Option<u64>,
592 ) -> Option<Box<InputCacheUsage>> {
593 if cached_input_tokens.is_none() && cache_creation_input_tokens.is_none() {
594 return None;
595 }
596 Some(Box::new(InputCacheUsage {
597 cached_input_tokens,
598 cache_creation_input_tokens,
599 }))
600 }
601
602 pub fn cached_input_tokens(&self) -> Option<u64> {
604 self.cache
605 .as_ref()
606 .and_then(|cache| cache.cached_input_tokens)
607 }
608
609 pub fn cache_creation_input_tokens(&self) -> Option<u64> {
611 self.cache
612 .as_ref()
613 .and_then(|cache| cache.cache_creation_input_tokens)
614 }
615
616 pub fn set_cached_input_tokens(&mut self, value: u64) {
618 self.cache
619 .get_or_insert_with(|| Box::new(InputCacheUsage::default()))
620 .cached_input_tokens = Some(value);
621 }
622
623 pub fn set_cache_creation_input_tokens(&mut self, value: u64) {
625 self.cache
626 .get_or_insert_with(|| Box::new(InputCacheUsage::default()))
627 .cache_creation_input_tokens = Some(value);
628 }
629}
630
631#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
633#[serde(rename_all = "snake_case")]
634pub enum StopReason {
635 EndTurn,
637 MaxTokens,
639 ToolUse,
641 ContentFilter,
643 Error,
645 Unknown,
647}
648
649#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
651pub struct UrlCitation {
652 pub start_index: usize,
654 pub end_index: usize,
656 pub url: String,
658 pub title: String,
660}
661
662#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
664pub struct ResponseOutput {
665 pub role: Role,
667 pub content: Vec<ContentBlock>,
669 #[serde(default, skip_serializing_if = "Vec::is_empty")]
671 pub url_citations: Vec<UrlCitation>,
672 pub stop_reason: Option<StopReason>,
674}
675
676#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
679#[serde(default)]
680pub struct AggLlmResponse {
681 pub id: Option<String>,
683 pub model: Option<String>,
685 pub outputs: Vec<ResponseOutput>,
687 pub usage: Usage,
689 pub extensions: ProviderExtensions,
691 pub preservation: PreservationMetadata,
693}
694
695impl AggLlmResponse {
696 pub fn first_output(&self) -> Option<&ResponseOutput> {
698 self.outputs.first()
699 }
700}
701
702#[cfg(test)]
703mod tests {
704 use serde_json::json;
705
706 use super::*;
707
708 #[test]
709 fn serde_uses_python_friendly_dictionary_shapes() -> Result<(), serde_json::Error> {
710 let request: LlmRequest = serde_json::from_value(json!({
711 "model": "auto",
712 "messages": [{
713 "role": "user",
714 "content": [{"type": "text", "text": "hello"}]
715 }]
716 }))?;
717 assert_eq!(request.messages[0], Message::text(Role::User, "hello"));
718
719 let tool_call = ContentBlock::ToolCall(ToolCall {
720 id: "call-1".to_string(),
721 name: "lookup".to_string(),
722 arguments: json!({"query": "rust"}),
723 });
724 assert_eq!(
725 serde_json::to_value(tool_call)?,
726 json!({
727 "type": "tool_call",
728 "id": "call-1",
729 "name": "lookup",
730 "arguments": {"query": "rust"}
731 })
732 );
733 Ok(())
734 }
735
736 #[test]
737 fn tool_deserialization_rejects_invalid_tagged_definitions() {
738 for definition in [
739 json!({"type": "mcp", "name": "inv", "parameters": {}}),
740 json!({"type": "provider", "provider": "x", "name": "n", "parameters": {}}),
741 json!({"type": "unknown", "name": "n", "parameters": {}}),
742 json!({"type": null, "name": "n", "parameters": {}}),
743 ] {
744 assert!(serde_json::from_value::<Tool>(definition).is_err());
745 }
746 let error = serde_json::from_value::<Tool>(json!({
747 "type": "mcp", "name": "inv", "url": "https://example.invalid/mcp",
748 "authorization_token": 5, "parameters": {}
749 }))
750 .expect_err("an invalid credential must not become a function")
751 .to_string();
752 assert!(error.contains("string"), "field error was lost: {error}");
753 }
754
755 #[test]
756 fn tool_catalog_preserves_function_json_and_backend_history() -> Result<(), serde_json::Error> {
757 let function = json!({"name": "lookup", "description": null, "parameters": {"type": "object"}, "strict": null});
758 let definitions = json!([
759 function,
760 {"type": "mcp", "name": "inventory", "url": "https://example.invalid/mcp", "require_approval": false},
761 {"type": "provider", "provider": "openai_responses", "raw": {"type": "mcp", "connector_id": "connector_example", "server_label": "example"}},
762 {
763 "type": "mcp", "name": "configured", "url": "https://example.invalid/mcp",
764 "default_config": {"enabled": false, "defer_loading": true},
765 "configs": {"lookup": {"enabled": true}, "search": {"defer_loading": false}}
766 }
767 ]);
768 let tools: Vec<Tool> = serde_json::from_value(definitions.clone())?;
769 assert!(matches!(&tools[0], Tool::Function(_)));
770 assert!(matches!(&tools[1], Tool::Mcp(_)));
771 assert!(matches!(&tools[2], Tool::Provider { .. }));
772 assert_eq!(serde_json::to_value(&tools)?, definitions);
773 let explicit_function: Tool = serde_json::from_value(json!({
774 "type": "function", "name": "lookup", "parameters": {"type": "object"}
775 }))?;
776 assert_eq!(explicit_function, tools[0]);
777
778 let history = json!([
779 {
780 "type": "mcp", "kind": "listing", "server_name": "inventory",
781 "id": "listing_1",
782 "tools": [{
783 "name": "lookup", "description": "Look up stock",
784 "input_schema": {"type": "object"},
785 "annotations": {"readOnlyHint": true}
786 }]
787 },
788 {"type": "mcp", "kind": "listing", "server_name": "empty", "tools": []},
789 {
790 "type": "mcp", "kind": "listing", "server_name": "unavailable",
791 "tools": [], "error": "Server unavailable"
792 },
793 {
794 "type": "mcp", "kind": "call", "server_name": "inventory",
795 "call": {"id": "call_1", "name": "lookup", "arguments": {}}
796 },
797 {
798 "type": "mcp", "kind": "result", "tool_call_id": "call_1",
799 "content": [{"type": "text", "text": "in stock"}], "is_error": false
800 }
801 ]);
802 let content: Vec<ContentBlock> = serde_json::from_value(history.clone())?;
803 assert_eq!(serde_json::to_value(&content)?, history);
804 Ok(())
805 }
806
807 #[test]
808 fn mcp_credentials_are_omitted_from_debug() -> Result<(), serde_json::Error> {
809 let token = "synthetic-mcp-debug-secret";
810 let url_secret = "synthetic-mcp-url-secret";
811 let url = format!("https://example.invalid/mcp?api_key={url_secret}");
812 let mcp: McpToolset = serde_json::from_value(json!({
813 "name": "inventory", "url": url,
814 "authorization_token": token
815 }))?;
816 let raw = json!({"mcp_servers": [{"authorization_token": token}]});
817 let mut request = LlmRequest {
818 tools: vec![
819 Tool::Mcp(mcp.clone()),
820 Tool::Provider {
821 provider: "openai_responses".into(),
822 raw: json!({"type": "mcp", "authorization": token}),
823 },
824 ],
825 ..LlmRequest::default()
826 };
827 request
828 .preservation
829 .requests
830 .insert("anthropic_messages".into(), raw.clone());
831 request.extensions.fields.insert("metadata".into(), raw);
832 let serialized = serde_json::to_value(&request)?;
833 for debug in [
834 format!("{mcp:?}"),
835 format!("{mcp:#?}"),
836 format!("{:?}", request.tools),
837 format!("{:?}", request.extensions),
838 format!("{:?}", request.preservation),
839 format!("{request:?}"),
840 format!("{request:#?}"),
841 ] {
842 assert!(!debug.contains(token), "Debug exposed an MCP credential");
843 assert!(
844 !debug.contains(url_secret),
845 "Debug exposed an MCP URL secret"
846 );
847 }
848 assert_eq!(serde_json::to_value(&request)?, serialized);
849 assert_eq!(serialized["tools"][0]["url"], url);
850 assert_eq!(serialized["tools"][0]["authorization_token"], token);
851 assert_eq!(serialized["tools"][1]["raw"]["authorization"], token);
852 Ok(())
853 }
854}