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 responses: Option<ResponsesItemMetadata>,
54}
55
56#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum ResponsesContentKind {
60 #[default]
62 Missing,
63 Null,
65 String,
67 Array,
69 InputString,
71}
72
73#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
75#[serde(default)]
76pub struct ResponsesItemMetadata {
77 pub fields: Map<String, Value>,
79 pub content_kind: ResponsesContentKind,
81}
82
83impl fmt::Debug for ResponsesItemMetadata {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 f.debug_struct("ResponsesItemMetadata")
86 .field("fields", &self.fields.keys())
87 .field("content_kind", &self.content_kind)
88 .finish()
89 }
90}
91
92#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
94#[serde(transparent)]
95pub struct ResponsesTextMetadata {
96 pub fields: Map<String, Value>,
98}
99
100impl fmt::Debug for ResponsesTextMetadata {
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 f.debug_struct("ResponsesTextMetadata")
103 .field("fields", &self.fields.keys())
104 .finish()
105 }
106}
107
108impl Message {
109 pub fn text(role: Role, text: impl Into<String>) -> Self {
111 Self {
112 role,
113 content: vec![ContentBlock::text(text)],
114 responses: None,
115 }
116 }
117
118 pub fn text_content(&self, separator: &str) -> Option<String> {
120 let parts = self
121 .content
122 .iter()
123 .filter_map(|block| match block {
124 ContentBlock::Text { text, .. } => Some(text.as_str()),
125 ContentBlock::Refusal { text } => Some(text.as_str()),
126 _ => None,
127 })
128 .collect::<Vec<_>>();
129 if parts.is_empty() {
130 None
131 } else {
132 Some(parts.join(separator))
133 }
134 }
135}
136
137#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
139#[serde(tag = "type", rename_all = "snake_case")]
140pub enum ContentBlock {
141 Text {
143 text: String,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 responses: Option<ResponsesTextMetadata>,
148 },
149 Reasoning {
151 text: String,
153 signature: Option<String>,
155 #[serde(default, skip_serializing_if = "Vec::is_empty")]
158 details: Vec<Value>,
159 },
160 Image {
162 source: ImageSource,
164 },
165 Audio {
167 source: MediaSource,
169 },
170 Video {
172 source: MediaSource,
174 },
175 File {
177 source: FileSource,
179 },
180 ToolCall(ToolCall),
182 ToolResult(ToolResult),
184 Mcp(McpContent),
186 Refusal {
188 text: String,
190 },
191 Unknown {
193 provider: FormatId,
195 raw: Value,
197 },
198}
199
200impl ContentBlock {
201 pub fn text(text: impl Into<String>) -> Self {
203 Self::Text {
204 text: text.into(),
205 responses: None,
206 }
207 }
208}
209
210#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
212#[serde(tag = "type", content = "data", rename_all = "snake_case")]
213pub enum ImageSource {
214 Url {
216 url: String,
218 detail: Option<String>,
220 },
221 Base64 {
223 media_type: Option<String>,
225 data: String,
227 },
228 Raw(Value),
230}
231
232#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
234#[serde(tag = "type", content = "data", rename_all = "snake_case")]
235pub enum FileSource {
236 FileId(String),
238 FileData {
240 data: String,
242 filename: Option<String>,
244 },
245 Raw(Value),
247}
248
249#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
251#[serde(tag = "type", content = "data", rename_all = "snake_case")]
252pub enum MediaSource {
253 Url {
255 url: String,
257 media_type: Option<String>,
259 },
260 Base64 {
262 media_type: Option<String>,
264 data: String,
266 },
267 Raw(Value),
269}
270
271#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
273pub struct ToolCall {
274 pub id: String,
276 pub name: String,
278 pub arguments: Value,
280}
281
282#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
284pub struct ToolResult {
285 pub tool_call_id: String,
287 pub content: Vec<ContentBlock>,
289 pub is_error: Option<bool>,
291}
292
293#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
295pub struct ToolDefinition {
296 pub name: String,
298 pub description: Option<String>,
300 pub parameters: Value,
302 pub strict: Option<bool>,
304}
305
306#[derive(Clone, PartialEq, Serialize)]
308#[serde(tag = "type", rename_all = "snake_case")]
309pub enum Tool {
310 Mcp(McpToolset),
312 Provider {
314 provider: FormatId,
316 raw: Value,
318 },
319 #[serde(untagged)]
321 Function(ToolDefinition),
322}
323
324impl<'de> Deserialize<'de> for Tool {
325 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
326 where
327 D: serde::Deserializer<'de>,
328 {
329 #[derive(Deserialize)]
331 #[serde(tag = "type", rename_all = "snake_case")]
332 enum TaggedTool {
333 Mcp(McpToolset),
334 Provider { provider: FormatId, raw: Value },
335 Function(ToolDefinition),
336 }
337
338 let value = Value::deserialize(deserializer)?;
339 if value.get("type").is_none() {
342 return serde_json::from_value(value)
343 .map(Self::Function)
344 .map_err(serde::de::Error::custom);
345 }
346 let tool = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
347 Ok(match tool {
348 TaggedTool::Mcp(mcp) => Self::Mcp(mcp),
349 TaggedTool::Provider { provider, raw } => Self::Provider { provider, raw },
350 TaggedTool::Function(function) => Self::Function(function),
351 })
352 }
353}
354
355impl fmt::Debug for Tool {
356 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
357 match self {
358 Self::Function(definition) => f.debug_tuple("Function").field(definition).finish(),
359 Self::Mcp(mcp) => f.debug_tuple("Mcp").field(mcp).finish(),
360 Self::Provider { provider, .. } => f
362 .debug_struct("Provider")
363 .field("provider", provider)
364 .finish_non_exhaustive(),
365 }
366 }
367}
368
369impl From<ToolDefinition> for Tool {
370 fn from(definition: ToolDefinition) -> Self {
371 Self::Function(definition)
372 }
373}
374
375impl Tool {
376 pub fn function(&self) -> Option<&ToolDefinition> {
378 match self {
379 Self::Function(definition) => Some(definition),
380 _ => None,
381 }
382 }
383}
384
385#[derive(Clone, PartialEq, Serialize, Deserialize)]
391pub struct McpToolset {
392 pub name: String,
394 pub url: String,
396 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub authorization_token: Option<String>,
399 #[serde(default, skip_serializing_if = "McpToolConfig::is_empty")]
401 pub default_config: McpToolConfig,
402 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
405 pub configs: BTreeMap<String, McpToolConfig>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub require_approval: Option<bool>,
411}
412
413impl fmt::Debug for McpToolset {
414 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
415 let Self {
416 name,
417 url: _,
418 authorization_token,
419 default_config,
420 configs,
421 require_approval,
422 } = self;
423 f.debug_struct("McpToolset")
424 .field("name", name)
425 .field("url", &"[REDACTED]")
427 .field(
428 "authorization_token",
429 &authorization_token.as_ref().map(|_| "[REDACTED]"),
430 )
431 .field("default_config", default_config)
432 .field("configs", configs)
433 .field("require_approval", require_approval)
434 .finish()
435 }
436}
437
438#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
440pub struct McpToolConfig {
441 #[serde(default, skip_serializing_if = "Option::is_none")]
443 pub enabled: Option<bool>,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub defer_loading: Option<bool>,
447}
448
449impl McpToolConfig {
450 fn is_empty(&self) -> bool {
451 self.enabled.is_none() && self.defer_loading.is_none()
452 }
453}
454
455#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
457pub struct McpListedTool {
458 pub name: String,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub description: Option<String>,
463 pub input_schema: Value,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub annotations: Option<Value>,
468}
469
470#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
472#[serde(tag = "kind", rename_all = "snake_case")]
473pub enum McpContent {
474 Listing {
476 server_name: String,
478 #[serde(default, skip_serializing_if = "Option::is_none")]
480 id: Option<String>,
481 tools: Vec<McpListedTool>,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
485 error: Option<String>,
486 },
487 Call {
489 server_name: String,
491 call: ToolCall,
493 },
494 Result(ToolResult),
496}
497
498#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
500#[serde(tag = "type", content = "data", rename_all = "snake_case")]
501pub enum ToolChoice {
502 Auto,
504 Required,
506 None,
508 Tool {
510 name: String,
512 },
513 Raw(Value),
515}
516
517#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
519pub struct SamplingParams {
520 pub temperature: Option<f64>,
522 pub top_p: Option<f64>,
524 pub top_k: Option<i64>,
526}
527
528#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
530pub struct OutputParams {
531 pub max_output_tokens: Option<u64>,
533 pub response_format: Option<Value>,
535 #[serde(default, skip_serializing_if = "Option::is_none")]
538 pub is_schema_enforced: Option<bool>,
539}
540
541#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
543pub struct ReasoningParams {
544 pub effort: Option<String>,
546 pub raw: Option<Value>,
548}
549
550#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
552#[serde(default)]
553pub struct ProviderExtensions {
554 pub fields: Map<String, Value>,
559}
560
561impl fmt::Debug for ProviderExtensions {
563 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
564 f.debug_struct("ProviderExtensions")
565 .field("field_count", &self.fields.len())
566 .finish_non_exhaustive()
567 }
568}
569
570#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
577#[serde(default)]
578pub struct PreservationMetadata {
579 pub requests: BTreeMap<FormatId, Value>,
581 pub responses: BTreeMap<FormatId, Value>,
583}
584
585impl fmt::Debug for PreservationMetadata {
587 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
588 f.debug_struct("PreservationMetadata")
589 .field("request_formats", &self.requests.keys())
590 .field("response_formats", &self.responses.keys())
591 .finish_non_exhaustive()
592 }
593}
594
595#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
597#[serde(default)]
598pub struct LlmRequest {
599 pub model: Option<String>,
604 pub instructions: Vec<InstructionBlock>,
606 pub messages: Vec<Message>,
608 #[serde(skip)]
615 pub temporary_note_count: usize,
616 #[serde(skip_serializing_if = "std::ops::Not::not")]
618 pub is_compaction: bool,
619 #[serde(skip_serializing_if = "std::ops::Not::not")]
621 pub has_compaction: bool,
622 pub tools: Vec<Tool>,
624 pub tool_choice: Option<ToolChoice>,
626 pub sampling: SamplingParams,
628 pub output: OutputParams,
630 pub reasoning: ReasoningParams,
632 pub stream: bool,
634 pub extensions: ProviderExtensions,
636 pub preservation: PreservationMetadata,
640}
641
642#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
644pub struct Usage {
645 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub provider_cost: Option<f64>,
648 pub input_tokens: Option<u64>,
651 #[serde(flatten)]
653 pub cache: Option<Box<InputCacheUsage>>,
654 pub output_tokens: Option<u64>,
656 pub total_tokens: Option<u64>,
661 pub reasoning_tokens: Option<u64>,
663}
664
665#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
667pub struct InputCacheUsage {
668 #[serde(default, skip_serializing_if = "Option::is_none")]
670 pub cache_creation_1h_input_tokens: Option<u64>,
671 pub cached_input_tokens: Option<u64>,
673 pub cache_creation_input_tokens: Option<u64>,
675}
676
677impl Usage {
678 pub fn cache_details(
680 cached_input_tokens: Option<u64>,
681 cache_creation_input_tokens: Option<u64>,
682 ) -> Option<Box<InputCacheUsage>> {
683 if cached_input_tokens.is_none() && cache_creation_input_tokens.is_none() {
684 return None;
685 }
686 Some(Box::new(InputCacheUsage {
687 cache_creation_1h_input_tokens: None,
688 cached_input_tokens,
689 cache_creation_input_tokens,
690 }))
691 }
692
693 pub fn cached_input_tokens(&self) -> Option<u64> {
695 self.cache
696 .as_ref()
697 .and_then(|cache| cache.cached_input_tokens)
698 }
699
700 pub fn cache_creation_input_tokens(&self) -> Option<u64> {
702 self.cache
703 .as_ref()
704 .and_then(|cache| cache.cache_creation_input_tokens)
705 }
706
707 pub fn set_cached_input_tokens(&mut self, value: u64) {
709 self.cache
710 .get_or_insert_with(|| Box::new(InputCacheUsage::default()))
711 .cached_input_tokens = Some(value);
712 }
713
714 pub fn set_cache_creation_input_tokens(&mut self, value: u64) {
716 self.cache
717 .get_or_insert_with(|| Box::new(InputCacheUsage::default()))
718 .cache_creation_input_tokens = Some(value);
719 }
720}
721
722#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
724#[serde(rename_all = "snake_case")]
725pub enum StopReason {
726 EndTurn,
728 MaxTokens,
730 ToolUse,
732 ContentFilter,
734 Error,
736 Unknown,
738}
739
740#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
742pub struct UrlCitation {
743 pub start_index: usize,
745 pub end_index: usize,
747 pub url: String,
749 pub title: String,
751}
752
753#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
755pub struct ResponseOutput {
756 pub role: Role,
758 pub content: Vec<ContentBlock>,
760 #[serde(default, skip_serializing_if = "Vec::is_empty")]
762 pub url_citations: Vec<UrlCitation>,
763 pub stop_reason: Option<StopReason>,
765}
766
767#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
770#[serde(default)]
771pub struct AggLlmResponse {
772 pub id: Option<String>,
774 pub model: Option<String>,
776 pub outputs: Vec<ResponseOutput>,
778 pub usage: Usage,
780 pub extensions: ProviderExtensions,
782 pub preservation: PreservationMetadata,
784}
785
786impl AggLlmResponse {
787 pub fn first_output(&self) -> Option<&ResponseOutput> {
789 self.outputs.first()
790 }
791}
792
793#[cfg(test)]
794mod tests {
795 use serde_json::json;
796
797 use super::*;
798
799 #[test]
800 fn serde_uses_python_friendly_dictionary_shapes() -> Result<(), serde_json::Error> {
801 let request: LlmRequest = serde_json::from_value(json!({
802 "model": "auto",
803 "messages": [{
804 "role": "user",
805 "content": [{"type": "text", "text": "hello"}]
806 }]
807 }))?;
808 assert_eq!(request.messages[0], Message::text(Role::User, "hello"));
809
810 let tool_call = ContentBlock::ToolCall(ToolCall {
811 id: "call-1".to_string(),
812 name: "lookup".to_string(),
813 arguments: json!({"query": "rust"}),
814 });
815 assert_eq!(
816 serde_json::to_value(tool_call)?,
817 json!({
818 "type": "tool_call",
819 "id": "call-1",
820 "name": "lookup",
821 "arguments": {"query": "rust"}
822 })
823 );
824 Ok(())
825 }
826
827 #[test]
828 fn tool_deserialization_rejects_invalid_tagged_definitions() {
829 for definition in [
830 json!({"type": "mcp", "name": "inv", "parameters": {}}),
831 json!({"type": "provider", "provider": "x", "name": "n", "parameters": {}}),
832 json!({"type": "unknown", "name": "n", "parameters": {}}),
833 json!({"type": null, "name": "n", "parameters": {}}),
834 ] {
835 assert!(serde_json::from_value::<Tool>(definition).is_err());
836 }
837 let error = serde_json::from_value::<Tool>(json!({
838 "type": "mcp", "name": "inv", "url": "https://example.invalid/mcp",
839 "authorization_token": 5, "parameters": {}
840 }))
841 .expect_err("an invalid credential must not become a function")
842 .to_string();
843 assert!(error.contains("string"), "field error was lost: {error}");
844 }
845
846 #[test]
847 fn tool_catalog_preserves_function_json_and_backend_history() -> Result<(), serde_json::Error> {
848 let function = json!({"name": "lookup", "description": null, "parameters": {"type": "object"}, "strict": null});
849 let definitions = json!([
850 function,
851 {"type": "mcp", "name": "inventory", "url": "https://example.invalid/mcp", "require_approval": false},
852 {"type": "provider", "provider": "openai_responses", "raw": {"type": "mcp", "connector_id": "connector_example", "server_label": "example"}},
853 {
854 "type": "mcp", "name": "configured", "url": "https://example.invalid/mcp",
855 "default_config": {"enabled": false, "defer_loading": true},
856 "configs": {"lookup": {"enabled": true}, "search": {"defer_loading": false}}
857 }
858 ]);
859 let tools: Vec<Tool> = serde_json::from_value(definitions.clone())?;
860 assert!(matches!(&tools[0], Tool::Function(_)));
861 assert!(matches!(&tools[1], Tool::Mcp(_)));
862 assert!(matches!(&tools[2], Tool::Provider { .. }));
863 assert_eq!(serde_json::to_value(&tools)?, definitions);
864 let explicit_function: Tool = serde_json::from_value(json!({
865 "type": "function", "name": "lookup", "parameters": {"type": "object"}
866 }))?;
867 assert_eq!(explicit_function, tools[0]);
868
869 let history = json!([
870 {
871 "type": "mcp", "kind": "listing", "server_name": "inventory",
872 "id": "listing_1",
873 "tools": [{
874 "name": "lookup", "description": "Look up stock",
875 "input_schema": {"type": "object"},
876 "annotations": {"readOnlyHint": true}
877 }]
878 },
879 {"type": "mcp", "kind": "listing", "server_name": "empty", "tools": []},
880 {
881 "type": "mcp", "kind": "listing", "server_name": "unavailable",
882 "tools": [], "error": "Server unavailable"
883 },
884 {
885 "type": "mcp", "kind": "call", "server_name": "inventory",
886 "call": {"id": "call_1", "name": "lookup", "arguments": {}}
887 },
888 {
889 "type": "mcp", "kind": "result", "tool_call_id": "call_1",
890 "content": [{"type": "text", "text": "in stock"}], "is_error": false
891 }
892 ]);
893 let content: Vec<ContentBlock> = serde_json::from_value(history.clone())?;
894 assert_eq!(serde_json::to_value(&content)?, history);
895 Ok(())
896 }
897
898 #[test]
899 fn mcp_credentials_are_omitted_from_debug() -> Result<(), serde_json::Error> {
900 let token = "synthetic-mcp-debug-secret";
901 let url_secret = "synthetic-mcp-url-secret";
902 let url = format!("https://example.invalid/mcp?api_key={url_secret}");
903 let mcp: McpToolset = serde_json::from_value(json!({
904 "name": "inventory", "url": url,
905 "authorization_token": token
906 }))?;
907 let raw = json!({"mcp_servers": [{"authorization_token": token}]});
908 let mut request = LlmRequest {
909 tools: vec![
910 Tool::Mcp(mcp.clone()),
911 Tool::Provider {
912 provider: "openai_responses".into(),
913 raw: json!({"type": "mcp", "authorization": token}),
914 },
915 ],
916 ..LlmRequest::default()
917 };
918 request
919 .preservation
920 .requests
921 .insert("anthropic_messages".into(), raw.clone());
922 request.extensions.fields.insert("metadata".into(), raw);
923 let serialized = serde_json::to_value(&request)?;
924 for debug in [
925 format!("{mcp:?}"),
926 format!("{mcp:#?}"),
927 format!("{:?}", request.tools),
928 format!("{:?}", request.extensions),
929 format!("{:?}", request.preservation),
930 format!("{request:?}"),
931 format!("{request:#?}"),
932 ] {
933 assert!(!debug.contains(token), "Debug exposed an MCP credential");
934 assert!(
935 !debug.contains(url_secret),
936 "Debug exposed an MCP URL secret"
937 );
938 }
939 assert_eq!(serde_json::to_value(&request)?, serialized);
940 assert_eq!(serialized["tools"][0]["url"], url);
941 assert_eq!(serialized["tools"][0]["authorization_token"], token);
942 assert_eq!(serialized["tools"][1]["raw"]["authorization"], token);
943 Ok(())
944 }
945}