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 #[serde(default, skip_serializing_if = "Option::is_none")]
564 pub provider_cost: Option<f64>,
565 pub input_tokens: Option<u64>,
568 #[serde(flatten)]
570 pub cache: Option<Box<InputCacheUsage>>,
571 pub output_tokens: Option<u64>,
573 pub total_tokens: Option<u64>,
578 pub reasoning_tokens: Option<u64>,
580}
581
582#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
584pub struct InputCacheUsage {
585 #[serde(default, skip_serializing_if = "Option::is_none")]
587 pub cache_creation_1h_input_tokens: Option<u64>,
588 pub cached_input_tokens: Option<u64>,
590 pub cache_creation_input_tokens: Option<u64>,
592}
593
594impl Usage {
595 pub fn cache_details(
597 cached_input_tokens: Option<u64>,
598 cache_creation_input_tokens: Option<u64>,
599 ) -> Option<Box<InputCacheUsage>> {
600 if cached_input_tokens.is_none() && cache_creation_input_tokens.is_none() {
601 return None;
602 }
603 Some(Box::new(InputCacheUsage {
604 cache_creation_1h_input_tokens: None,
605 cached_input_tokens,
606 cache_creation_input_tokens,
607 }))
608 }
609
610 pub fn cached_input_tokens(&self) -> Option<u64> {
612 self.cache
613 .as_ref()
614 .and_then(|cache| cache.cached_input_tokens)
615 }
616
617 pub fn cache_creation_input_tokens(&self) -> Option<u64> {
619 self.cache
620 .as_ref()
621 .and_then(|cache| cache.cache_creation_input_tokens)
622 }
623
624 pub fn set_cached_input_tokens(&mut self, value: u64) {
626 self.cache
627 .get_or_insert_with(|| Box::new(InputCacheUsage::default()))
628 .cached_input_tokens = Some(value);
629 }
630
631 pub fn set_cache_creation_input_tokens(&mut self, value: u64) {
633 self.cache
634 .get_or_insert_with(|| Box::new(InputCacheUsage::default()))
635 .cache_creation_input_tokens = Some(value);
636 }
637}
638
639#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
641#[serde(rename_all = "snake_case")]
642pub enum StopReason {
643 EndTurn,
645 MaxTokens,
647 ToolUse,
649 ContentFilter,
651 Error,
653 Unknown,
655}
656
657#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
659pub struct UrlCitation {
660 pub start_index: usize,
662 pub end_index: usize,
664 pub url: String,
666 pub title: String,
668}
669
670#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
672pub struct ResponseOutput {
673 pub role: Role,
675 pub content: Vec<ContentBlock>,
677 #[serde(default, skip_serializing_if = "Vec::is_empty")]
679 pub url_citations: Vec<UrlCitation>,
680 pub stop_reason: Option<StopReason>,
682}
683
684#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
687#[serde(default)]
688pub struct AggLlmResponse {
689 pub id: Option<String>,
691 pub model: Option<String>,
693 pub outputs: Vec<ResponseOutput>,
695 pub usage: Usage,
697 pub extensions: ProviderExtensions,
699 pub preservation: PreservationMetadata,
701}
702
703impl AggLlmResponse {
704 pub fn first_output(&self) -> Option<&ResponseOutput> {
706 self.outputs.first()
707 }
708}
709
710#[cfg(test)]
711mod tests {
712 use serde_json::json;
713
714 use super::*;
715
716 #[test]
717 fn serde_uses_python_friendly_dictionary_shapes() -> Result<(), serde_json::Error> {
718 let request: LlmRequest = serde_json::from_value(json!({
719 "model": "auto",
720 "messages": [{
721 "role": "user",
722 "content": [{"type": "text", "text": "hello"}]
723 }]
724 }))?;
725 assert_eq!(request.messages[0], Message::text(Role::User, "hello"));
726
727 let tool_call = ContentBlock::ToolCall(ToolCall {
728 id: "call-1".to_string(),
729 name: "lookup".to_string(),
730 arguments: json!({"query": "rust"}),
731 });
732 assert_eq!(
733 serde_json::to_value(tool_call)?,
734 json!({
735 "type": "tool_call",
736 "id": "call-1",
737 "name": "lookup",
738 "arguments": {"query": "rust"}
739 })
740 );
741 Ok(())
742 }
743
744 #[test]
745 fn tool_deserialization_rejects_invalid_tagged_definitions() {
746 for definition in [
747 json!({"type": "mcp", "name": "inv", "parameters": {}}),
748 json!({"type": "provider", "provider": "x", "name": "n", "parameters": {}}),
749 json!({"type": "unknown", "name": "n", "parameters": {}}),
750 json!({"type": null, "name": "n", "parameters": {}}),
751 ] {
752 assert!(serde_json::from_value::<Tool>(definition).is_err());
753 }
754 let error = serde_json::from_value::<Tool>(json!({
755 "type": "mcp", "name": "inv", "url": "https://example.invalid/mcp",
756 "authorization_token": 5, "parameters": {}
757 }))
758 .expect_err("an invalid credential must not become a function")
759 .to_string();
760 assert!(error.contains("string"), "field error was lost: {error}");
761 }
762
763 #[test]
764 fn tool_catalog_preserves_function_json_and_backend_history() -> Result<(), serde_json::Error> {
765 let function = json!({"name": "lookup", "description": null, "parameters": {"type": "object"}, "strict": null});
766 let definitions = json!([
767 function,
768 {"type": "mcp", "name": "inventory", "url": "https://example.invalid/mcp", "require_approval": false},
769 {"type": "provider", "provider": "openai_responses", "raw": {"type": "mcp", "connector_id": "connector_example", "server_label": "example"}},
770 {
771 "type": "mcp", "name": "configured", "url": "https://example.invalid/mcp",
772 "default_config": {"enabled": false, "defer_loading": true},
773 "configs": {"lookup": {"enabled": true}, "search": {"defer_loading": false}}
774 }
775 ]);
776 let tools: Vec<Tool> = serde_json::from_value(definitions.clone())?;
777 assert!(matches!(&tools[0], Tool::Function(_)));
778 assert!(matches!(&tools[1], Tool::Mcp(_)));
779 assert!(matches!(&tools[2], Tool::Provider { .. }));
780 assert_eq!(serde_json::to_value(&tools)?, definitions);
781 let explicit_function: Tool = serde_json::from_value(json!({
782 "type": "function", "name": "lookup", "parameters": {"type": "object"}
783 }))?;
784 assert_eq!(explicit_function, tools[0]);
785
786 let history = json!([
787 {
788 "type": "mcp", "kind": "listing", "server_name": "inventory",
789 "id": "listing_1",
790 "tools": [{
791 "name": "lookup", "description": "Look up stock",
792 "input_schema": {"type": "object"},
793 "annotations": {"readOnlyHint": true}
794 }]
795 },
796 {"type": "mcp", "kind": "listing", "server_name": "empty", "tools": []},
797 {
798 "type": "mcp", "kind": "listing", "server_name": "unavailable",
799 "tools": [], "error": "Server unavailable"
800 },
801 {
802 "type": "mcp", "kind": "call", "server_name": "inventory",
803 "call": {"id": "call_1", "name": "lookup", "arguments": {}}
804 },
805 {
806 "type": "mcp", "kind": "result", "tool_call_id": "call_1",
807 "content": [{"type": "text", "text": "in stock"}], "is_error": false
808 }
809 ]);
810 let content: Vec<ContentBlock> = serde_json::from_value(history.clone())?;
811 assert_eq!(serde_json::to_value(&content)?, history);
812 Ok(())
813 }
814
815 #[test]
816 fn mcp_credentials_are_omitted_from_debug() -> Result<(), serde_json::Error> {
817 let token = "synthetic-mcp-debug-secret";
818 let url_secret = "synthetic-mcp-url-secret";
819 let url = format!("https://example.invalid/mcp?api_key={url_secret}");
820 let mcp: McpToolset = serde_json::from_value(json!({
821 "name": "inventory", "url": url,
822 "authorization_token": token
823 }))?;
824 let raw = json!({"mcp_servers": [{"authorization_token": token}]});
825 let mut request = LlmRequest {
826 tools: vec![
827 Tool::Mcp(mcp.clone()),
828 Tool::Provider {
829 provider: "openai_responses".into(),
830 raw: json!({"type": "mcp", "authorization": token}),
831 },
832 ],
833 ..LlmRequest::default()
834 };
835 request
836 .preservation
837 .requests
838 .insert("anthropic_messages".into(), raw.clone());
839 request.extensions.fields.insert("metadata".into(), raw);
840 let serialized = serde_json::to_value(&request)?;
841 for debug in [
842 format!("{mcp:?}"),
843 format!("{mcp:#?}"),
844 format!("{:?}", request.tools),
845 format!("{:?}", request.extensions),
846 format!("{:?}", request.preservation),
847 format!("{request:?}"),
848 format!("{request:#?}"),
849 ] {
850 assert!(!debug.contains(token), "Debug exposed an MCP credential");
851 assert!(
852 !debug.contains(url_secret),
853 "Debug exposed an MCP URL secret"
854 );
855 }
856 assert_eq!(serde_json::to_value(&request)?, serialized);
857 assert_eq!(serialized["tools"][0]["url"], url);
858 assert_eq!(serialized["tools"][0]["authorization_token"], token);
859 assert_eq!(serialized["tools"][1]["raw"]["authorization"], token);
860 Ok(())
861 }
862}