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 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub phase: Option<String>,
54}
55
56impl Message {
57 pub fn new(role: Role, content: Vec<ContentBlock>) -> Self {
59 Self {
60 role,
61 content,
62 phase: None,
63 }
64 }
65
66 pub fn text(role: Role, text: impl Into<String>) -> Self {
68 Self::new(role, vec![ContentBlock::Text { text: text.into() }])
69 }
70
71 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
92#[serde(tag = "type", rename_all = "snake_case")]
93pub enum ContentBlock {
94 Text {
96 text: String,
98 },
99 Reasoning {
101 text: String,
103 signature: Option<String>,
105 #[serde(default, skip_serializing_if = "Vec::is_empty")]
108 details: Vec<Value>,
109 },
110 Image {
112 source: ImageSource,
114 },
115 Audio {
117 source: MediaSource,
119 },
120 Video {
122 source: MediaSource,
124 },
125 File {
127 source: FileSource,
129 },
130 ToolCall(ToolCall),
132 ToolResult(ToolResult),
134 Mcp(McpContent),
136 Refusal {
138 text: String,
140 },
141 Unknown {
143 provider: FormatId,
145 raw: Value,
147 },
148}
149
150#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
152#[serde(tag = "type", content = "data", rename_all = "snake_case")]
153pub enum ImageSource {
154 Url {
156 url: String,
158 detail: Option<String>,
160 },
161 Base64 {
163 media_type: Option<String>,
165 data: String,
167 },
168 Raw(Value),
170}
171
172#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
174#[serde(tag = "type", content = "data", rename_all = "snake_case")]
175pub enum FileSource {
176 FileId(String),
178 FileData {
180 data: String,
182 filename: Option<String>,
184 },
185 Raw(Value),
187}
188
189#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
191#[serde(tag = "type", content = "data", rename_all = "snake_case")]
192pub enum MediaSource {
193 Url {
195 url: String,
197 media_type: Option<String>,
199 },
200 Base64 {
202 media_type: Option<String>,
204 data: String,
206 },
207 Raw(Value),
209}
210
211#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
213pub struct ToolCall {
214 pub id: String,
216 pub name: String,
218 pub arguments: Value,
220}
221
222#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
224pub struct ToolResult {
225 pub tool_call_id: String,
227 pub content: Vec<ContentBlock>,
229 pub is_error: Option<bool>,
231}
232
233#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
235pub struct ToolDefinition {
236 pub name: String,
238 pub description: Option<String>,
240 pub parameters: Value,
242 pub strict: Option<bool>,
244}
245
246#[derive(Clone, PartialEq, Serialize)]
248#[serde(tag = "type", rename_all = "snake_case")]
249pub enum Tool {
250 Mcp(McpToolset),
252 Provider {
254 provider: FormatId,
256 raw: Value,
258 },
259 #[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 #[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 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 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 pub fn function(&self) -> Option<&ToolDefinition> {
318 match self {
319 Self::Function(definition) => Some(definition),
320 _ => None,
321 }
322 }
323}
324
325#[derive(Clone, PartialEq, Serialize, Deserialize)]
331pub struct McpToolset {
332 pub name: String,
334 pub url: String,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub authorization_token: Option<String>,
339 #[serde(default, skip_serializing_if = "McpToolConfig::is_empty")]
341 pub default_config: McpToolConfig,
342 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
345 pub configs: BTreeMap<String, McpToolConfig>,
346 #[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 .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#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
380pub struct McpToolConfig {
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub enabled: Option<bool>,
384 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
397pub struct McpListedTool {
398 pub name: String,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub description: Option<String>,
403 pub input_schema: Value,
405 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub annotations: Option<Value>,
408}
409
410#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
412#[serde(tag = "kind", rename_all = "snake_case")]
413pub enum McpContent {
414 Listing {
416 server_name: String,
418 #[serde(default, skip_serializing_if = "Option::is_none")]
420 id: Option<String>,
421 tools: Vec<McpListedTool>,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
425 error: Option<String>,
426 },
427 Call {
429 server_name: String,
431 call: ToolCall,
433 },
434 Result(ToolResult),
436}
437
438#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
440#[serde(tag = "type", content = "data", rename_all = "snake_case")]
441pub enum ToolChoice {
442 Auto,
444 Required,
446 None,
448 Tool {
450 name: String,
452 },
453 Raw(Value),
455}
456
457#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
459pub struct SamplingParams {
460 pub temperature: Option<f64>,
462 pub top_p: Option<f64>,
464 pub top_k: Option<i64>,
466}
467
468#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
470pub struct OutputParams {
471 pub max_output_tokens: Option<u64>,
473 pub response_format: Option<Value>,
475 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub is_schema_enforced: Option<bool>,
479}
480
481#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
483pub struct ReasoningParams {
484 pub effort: Option<String>,
486 pub raw: Option<Value>,
488}
489
490#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
492#[serde(default)]
493pub struct ProviderExtensions {
494 pub fields: Map<String, Value>,
499}
500
501impl 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#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
517#[serde(default)]
518pub struct PreservationMetadata {
519 pub requests: BTreeMap<FormatId, Value>,
521 pub responses: BTreeMap<FormatId, Value>,
523}
524
525impl 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#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
537#[serde(default)]
538pub struct LlmRequest {
539 pub model: Option<String>,
544 pub instructions: Vec<InstructionBlock>,
546 pub messages: Vec<Message>,
548 #[serde(skip)]
555 pub temporary_note_count: usize,
556 #[serde(skip_serializing_if = "std::ops::Not::not")]
558 pub is_compaction: bool,
559 #[serde(skip_serializing_if = "std::ops::Not::not")]
561 pub has_compaction: bool,
562 pub tools: Vec<Tool>,
564 pub tool_choice: Option<ToolChoice>,
566 pub sampling: SamplingParams,
568 pub output: OutputParams,
570 pub reasoning: ReasoningParams,
572 pub stream: bool,
574 pub extensions: ProviderExtensions,
576 pub preservation: PreservationMetadata,
580}
581
582#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
584pub struct Usage {
585 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub provider_cost: Option<f64>,
588 pub input_tokens: Option<u64>,
591 #[serde(flatten)]
593 pub cache: Option<Box<InputCacheUsage>>,
594 pub output_tokens: Option<u64>,
596 pub total_tokens: Option<u64>,
601 pub reasoning_tokens: Option<u64>,
603}
604
605#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
607pub struct InputCacheUsage {
608 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub cache_creation_1h_input_tokens: Option<u64>,
611 pub cached_input_tokens: Option<u64>,
613 pub cache_creation_input_tokens: Option<u64>,
615}
616
617impl Usage {
618 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 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 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 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
664#[serde(rename_all = "snake_case")]
665pub enum StopReason {
666 EndTurn,
668 MaxTokens,
670 ToolUse,
672 ContentFilter,
674 Error,
676 Unknown,
678}
679
680#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
682pub struct UrlCitation {
683 pub start_index: usize,
685 pub end_index: usize,
687 pub url: String,
689 pub title: String,
691}
692
693#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
695pub struct ResponseOutput {
696 pub role: Role,
698 pub content: Vec<ContentBlock>,
700 #[serde(default, skip_serializing_if = "Vec::is_empty")]
702 pub url_citations: Vec<UrlCitation>,
703 pub stop_reason: Option<StopReason>,
705}
706
707#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
710#[serde(default)]
711pub struct AggLlmResponse {
712 pub id: Option<String>,
714 pub model: Option<String>,
716 pub outputs: Vec<ResponseOutput>,
718 pub usage: Usage,
720 pub extensions: ProviderExtensions,
722 pub preservation: PreservationMetadata,
724}
725
726impl AggLlmResponse {
727 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}