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