use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::fmt; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use crate::formats::openai::responses::openai_responses_message_item_id; use crate::formats::openai::responses::{ decode_gemini_tool_signature_carrier, GeminiToolSignatureCarrierDirection, }; use crate::formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort; use crate::formats::shared::model_directives::ReasoningEffort; use crate::formats::shared::response::remove_empty_pages_from_tool_input_value; pub use crate::protocol::stream::{CanonicalStreamEvent, CanonicalStreamFrame}; pub(crate) const OPENAI_RESPONSES_EXTENSION_NAMESPACE: &str = "openai_responses"; pub(crate) const OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE: &str = "openai_cli"; pub(crate) const CLAUDE_EXTENSION_NAMESPACE: &str = "claude"; const AETHER_EXTENSION_NAMESPACE: &str = "aether"; const CLAUDE_MESSAGES_REQUEST_SOURCE_MARKER: &str = "claude_messages_request"; const CLAUDE_SYSTEM_SOURCE_MARKER: &str = "claude_system"; const CLAUDE_THINKING_SOURCE_MARKER: &str = "claude_thinking"; const CLAUDE_TOOL_RESULT_SOURCE_MARKER: &str = "claude_tool_result"; const CLAUDE_RAW_SOURCE_MARKER: &str = "claude_raw"; const OPENAI_THINKING_SOURCE_MARKER: &str = "openai_thinking"; const OPENAI_CUSTOM_TOOL_CALL_SOURCE_MARKER: &str = "openai_custom_tool_call"; const OPENAI_OUTPUT_AUDIO_SOURCE_MARKER: &str = "openai_output_audio"; const OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER: &str = "openai_chat_tool_result"; const OPENAI_RESPONSES_TOOL_RESULT_SOURCE_MARKER: &str = "openai_responses_tool_result"; const OPENAI_RESPONSES_INPUT_MESSAGE_SOURCE_MARKER: &str = "openai_responses_input_message"; const OPENAI_RESPONSES_RAW_SOURCE_MARKER: &str = "openai_responses_raw"; const OPENAI_RESPONSES_RAW_CONTENT_SOURCE_MARKER: &str = "openai_responses_raw_content"; const OPENAI_RESPONSES_CONTENT_MARKER: &str = "openai_responses_content"; const OPENAI_CHAT_TOOL_ERROR_PREFIX: &str = "[tool error]"; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CanonicalRole { User, Assistant, System, Developer, Tool, #[default] Unknown, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CanonicalStopReason { EndTurn, MaxTokens, StopSequence, ToolUse, PauseTurn, Refusal, ContentFiltered, Unknown, } #[derive(Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum CanonicalToolChoice { Auto, None, Required, Tool { name: String }, } #[derive(Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum CanonicalContentBlock { Text { text: String, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] extensions: BTreeMap, }, Thinking { text: String, #[serde(default, skip_serializing_if = "Option::is_none")] signature: Option, #[serde(default, skip_serializing_if = "Option::is_none")] encrypted_content: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] extensions: BTreeMap, }, Image { #[serde(default, skip_serializing_if = "Option::is_none")] data: Option, #[serde(default, skip_serializing_if = "Option::is_none")] url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] media_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] detail: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] extensions: BTreeMap, }, File { #[serde(default, skip_serializing_if = "Option::is_none")] data: Option, #[serde(default, skip_serializing_if = "Option::is_none")] file_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] file_url: Option, #[serde(default, skip_serializing_if = "Option::is_none")] media_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] filename: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] extensions: BTreeMap, }, Audio { #[serde(default, skip_serializing_if = "Option::is_none")] data: Option, #[serde(default, skip_serializing_if = "Option::is_none")] media_type: Option, #[serde(default, skip_serializing_if = "Option::is_none")] format: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] extensions: BTreeMap, }, ToolUse { id: String, name: String, #[serde(default)] input: Value, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] extensions: BTreeMap, }, ToolResult { tool_use_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] output: Option, #[serde(default, skip_serializing_if = "Option::is_none")] content_text: Option, #[serde(default)] is_error: bool, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] extensions: BTreeMap, }, Unknown { raw_type: String, payload: Value, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] extensions: BTreeMap, }, } #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct CanonicalInstruction { pub role: CanonicalRole, #[serde(default)] pub text: String, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct CanonicalMessage { pub role: CanonicalRole, #[serde(default)] pub content: Vec, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } #[derive(Clone, Default, PartialEq, Serialize, Deserialize)] pub struct CanonicalGenerationConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub max_tokens: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub temperature: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub top_p: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub top_k: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub stop_sequences: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub n: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub presence_penalty: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub frequency_penalty: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub seed: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub logprobs: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub top_logprobs: Option, } #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct CanonicalToolDefinition { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub parameters: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub strict: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct CanonicalThinkingConfig { #[serde(default)] pub enabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub budget_tokens: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct CanonicalResponseFormat { pub format_type: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub json_schema: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } #[derive(Clone, Default, PartialEq, Serialize, Deserialize)] pub struct CanonicalUsage { #[serde(default)] pub input_tokens: u64, /// True when `input_tokens` already includes cache read and cache creation /// input tokens. Claude-style usage leaves cached input tokens separate. #[serde(default, skip_serializing_if = "is_false")] pub input_tokens_include_cache: bool, #[serde(default)] pub output_tokens: u64, #[serde(default)] pub total_tokens: u64, #[serde(default)] pub cache_read_tokens: u64, #[serde(default)] pub cache_write_tokens: u64, #[serde(default)] pub cache_creation_ephemeral_5m_tokens: u64, #[serde(default)] pub cache_creation_ephemeral_1h_tokens: u64, #[serde(default)] pub reasoning_tokens: u64, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } fn is_false(value: &bool) -> bool { !*value } #[derive(Clone, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum CanonicalEmbeddingInput { String(String), StringArray(Vec), TokenArray(Vec), TokenArrayArray(Vec>), Multimodal(Vec), } #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct CanonicalEmbeddingContent { #[serde(default, skip_serializing_if = "Option::is_none")] pub text: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub image: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub video: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub multi_images: Option>, } impl CanonicalEmbeddingInput { pub(crate) fn is_empty(&self) -> bool { match self { Self::String(value) => value.trim().is_empty(), Self::StringArray(values) => { values.is_empty() || values.iter().any(|value| value.trim().is_empty()) } Self::TokenArray(values) => values.is_empty(), Self::TokenArrayArray(values) => values.is_empty() || values.iter().any(Vec::is_empty), Self::Multimodal(values) => { values.is_empty() || values.iter().any(CanonicalEmbeddingContent::is_empty) } } } pub(crate) fn as_string_items(&self) -> Option> { match self { Self::String(value) => Some(vec![value.as_str()]), Self::StringArray(values) => Some(values.iter().map(String::as_str).collect()), Self::TokenArray(_) | Self::TokenArrayArray(_) | Self::Multimodal(_) => None, } } } impl CanonicalEmbeddingContent { pub(crate) fn is_empty(&self) -> bool { let text_empty = self .text .as_ref() .is_some_and(|value| value.trim().is_empty()); let image_empty = self .image .as_ref() .is_some_and(|value| value.trim().is_empty()); let video_empty = self .video .as_ref() .is_some_and(|value| value.trim().is_empty()); let multi_images_empty = self.multi_images.as_ref().is_some_and(|values| { values.is_empty() || values.iter().any(|value| value.trim().is_empty()) }); let has_any = self .text .as_ref() .is_some_and(|value| !value.trim().is_empty()) || self .image .as_ref() .is_some_and(|value| !value.trim().is_empty()) || self .video .as_ref() .is_some_and(|value| !value.trim().is_empty()) || self.multi_images.as_ref().is_some_and(|values| { !values.is_empty() && values.iter().all(|value| !value.trim().is_empty()) }); !has_any || text_empty || image_empty || video_empty || multi_images_empty } } #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct CanonicalEmbeddingRequest { pub input: CanonicalEmbeddingInput, #[serde(default, skip_serializing_if = "Option::is_none")] pub encoding_format: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub dimensions: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub task: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub user: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub parameters: Option>, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct CanonicalRerankRequest { pub query: String, #[serde(default)] pub documents: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub top_n: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub return_documents: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } impl CanonicalRerankRequest { pub(crate) fn is_empty(&self) -> bool { self.query.trim().is_empty() || self.documents.is_empty() || self.documents.iter().any(rerank_document_is_empty) } } #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct CanonicalEmbedding { #[serde(default)] pub index: usize, #[serde(default)] pub embedding: Vec, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct CanonicalEmbeddingResponse { pub id: String, pub model: String, #[serde(default)] pub embeddings: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub usage: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } #[derive(Clone, Default, PartialEq, Serialize, Deserialize)] pub struct CanonicalRequest { #[serde(default)] pub model: String, #[serde(default)] pub instructions: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub system: Option, #[serde(default)] pub messages: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub embedding: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub rerank: Option, #[serde(default)] pub generation: CanonicalGenerationConfig, #[serde(default)] pub tools: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub tool_choice: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub thinking: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub response_format: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub parallel_tool_calls: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub metadata: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct CanonicalResponseOutput { #[serde(default)] pub index: usize, #[serde(default)] pub role: CanonicalRole, #[serde(default)] pub content: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub stop_reason: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } impl Default for CanonicalResponseOutput { fn default() -> Self { Self { index: 0, role: CanonicalRole::Assistant, content: Vec::new(), stop_reason: None, extensions: BTreeMap::new(), } } } #[derive(Clone, PartialEq, Serialize, Deserialize)] pub struct CanonicalResponse { pub id: String, pub model: String, #[serde(default)] pub outputs: Vec, #[serde(default)] pub content: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub stop_reason: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub usage: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub extensions: BTreeMap, } fn debug_json_bytes(value: &Value) -> Option { serde_json::to_vec(value).ok().map(|bytes| bytes.len()) } fn debug_json_map_bytes(value: &Map) -> Option { serde_json::to_vec(value).ok().map(|bytes| bytes.len()) } fn debug_json_option_bytes(value: Option<&Value>) -> Option { value.and_then(debug_json_bytes) } fn debug_string_len(value: Option<&str>) -> Option { value.map(str::len) } fn debug_string_list_summary(value: Option<&Vec>) -> Option<(usize, usize)> { value.map(|values| (values.len(), values.iter().map(String::len).sum::())) } fn debug_json_list_summary(value: &[Value]) -> (usize, usize) { ( value.len(), value.iter().filter_map(debug_json_bytes).sum::(), ) } impl fmt::Debug for CanonicalToolChoice { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let mut debug = formatter.debug_struct("CanonicalToolChoice"); match self { Self::Auto => debug.field("kind", &"auto"), Self::None => debug.field("kind", &"none"), Self::Required => debug.field("kind", &"required"), Self::Tool { name } => debug.field("kind", &"tool").field("name_len", &name.len()), } .finish() } } impl fmt::Debug for CanonicalContentBlock { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let mut debug = formatter.debug_struct("CanonicalContentBlock"); match self { Self::Text { text, extensions } => debug .field("kind", &"text") .field("text_len", &text.len()) .field("extension_count", &extensions.len()), Self::Thinking { text, signature, encrypted_content, extensions, } => debug .field("kind", &"thinking") .field("text_len", &text.len()) .field("signature_len", &debug_string_len(signature.as_deref())) .field( "encrypted_content_len", &debug_string_len(encrypted_content.as_deref()), ) .field("extension_count", &extensions.len()), Self::Image { data, url, media_type, detail, extensions, } => debug .field("kind", &"image") .field("data_len", &debug_string_len(data.as_deref())) .field("url_len", &debug_string_len(url.as_deref())) .field("media_type", media_type) .field("detail", detail) .field("extension_count", &extensions.len()), Self::File { data, file_id, file_url, media_type, filename, extensions, } => debug .field("kind", &"file") .field("data_len", &debug_string_len(data.as_deref())) .field("file_id_len", &debug_string_len(file_id.as_deref())) .field("file_url_len", &debug_string_len(file_url.as_deref())) .field("media_type", media_type) .field("filename_len", &debug_string_len(filename.as_deref())) .field("extension_count", &extensions.len()), Self::Audio { data, media_type, format, extensions, } => debug .field("kind", &"audio") .field("data_len", &debug_string_len(data.as_deref())) .field("media_type", media_type) .field("format", format) .field("extension_count", &extensions.len()), Self::ToolUse { id, name, input, extensions, } => debug .field("kind", &"tool_use") .field("id_len", &id.len()) .field("name_len", &name.len()) .field("input_bytes", &debug_json_bytes(input)) .field("extension_count", &extensions.len()), Self::ToolResult { tool_use_id, name, output, content_text, is_error, extensions, } => debug .field("kind", &"tool_result") .field("tool_use_id_len", &tool_use_id.len()) .field("name_len", &debug_string_len(name.as_deref())) .field("output_bytes", &debug_json_option_bytes(output.as_ref())) .field( "content_text_len", &debug_string_len(content_text.as_deref()), ) .field("is_error", is_error) .field("extension_count", &extensions.len()), Self::Unknown { raw_type, payload, extensions, } => debug .field("kind", &"unknown") .field("raw_type_len", &raw_type.len()) .field("payload_bytes", &debug_json_bytes(payload)) .field("extension_count", &extensions.len()), } .finish() } } impl fmt::Debug for CanonicalInstruction { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CanonicalInstruction") .field("role", &self.role) .field("text_len", &self.text.len()) .field("extension_count", &self.extensions.len()) .finish() } } impl fmt::Debug for CanonicalMessage { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CanonicalMessage") .field("role", &self.role) .field("content_count", &self.content.len()) .field("extension_count", &self.extensions.len()) .finish() } } impl fmt::Debug for CanonicalGenerationConfig { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CanonicalGenerationConfig") .field("max_tokens", &self.max_tokens) .field("temperature", &self.temperature) .field("top_p", &self.top_p) .field("top_k", &self.top_k) .field( "stop_sequences", &debug_string_list_summary(self.stop_sequences.as_ref()), ) .field("n", &self.n) .field("presence_penalty", &self.presence_penalty) .field("frequency_penalty", &self.frequency_penalty) .field("seed", &self.seed) .field("logprobs", &self.logprobs) .field("top_logprobs", &self.top_logprobs) .finish() } } impl fmt::Debug for CanonicalToolDefinition { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CanonicalToolDefinition") .field("name_len", &self.name.len()) .field( "description_len", &debug_string_len(self.description.as_deref()), ) .field( "parameters_bytes", &debug_json_option_bytes(self.parameters.as_ref()), ) .field("strict", &self.strict) .field("extension_count", &self.extensions.len()) .finish() } } impl fmt::Debug for CanonicalThinkingConfig { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CanonicalThinkingConfig") .field("enabled", &self.enabled) .field("budget_tokens", &self.budget_tokens) .field("extension_count", &self.extensions.len()) .finish() } } impl fmt::Debug for CanonicalResponseFormat { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CanonicalResponseFormat") .field("format_type_len", &self.format_type.len()) .field( "json_schema_bytes", &debug_json_option_bytes(self.json_schema.as_ref()), ) .field("extension_count", &self.extensions.len()) .finish() } } impl fmt::Debug for CanonicalUsage { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CanonicalUsage") .field("input_tokens", &self.input_tokens) .field( "input_tokens_include_cache", &self.input_tokens_include_cache, ) .field("output_tokens", &self.output_tokens) .field("total_tokens", &self.total_tokens) .field("cache_read_tokens", &self.cache_read_tokens) .field("cache_write_tokens", &self.cache_write_tokens) .field( "cache_creation_ephemeral_5m_tokens", &self.cache_creation_ephemeral_5m_tokens, ) .field( "cache_creation_ephemeral_1h_tokens", &self.cache_creation_ephemeral_1h_tokens, ) .field("reasoning_tokens", &self.reasoning_tokens) .field("extension_count", &self.extensions.len()) .finish() } } impl fmt::Debug for CanonicalEmbeddingInput { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let mut debug = formatter.debug_struct("CanonicalEmbeddingInput"); match self { Self::String(value) => debug .field("kind", &"string") .field("item_count", &1) .field("total_text_bytes", &value.len()), Self::StringArray(values) => debug .field("kind", &"string_array") .field("item_count", &values.len()) .field( "total_text_bytes", &values.iter().map(String::len).sum::(), ), Self::TokenArray(values) => debug .field("kind", &"token_array") .field("item_count", &values.len()), Self::TokenArrayArray(values) => debug .field("kind", &"token_array_array") .field("item_count", &values.len()) .field( "total_token_count", &values.iter().map(Vec::len).sum::(), ), Self::Multimodal(values) => debug .field("kind", &"multimodal") .field("item_count", &values.len()), } .finish() } } impl fmt::Debug for CanonicalEmbeddingContent { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CanonicalEmbeddingContent") .field("text_len", &debug_string_len(self.text.as_deref())) .field("image_len", &debug_string_len(self.image.as_deref())) .field("video_len", &debug_string_len(self.video.as_deref())) .field( "multi_images_summary", &self .multi_images .as_ref() .map(|images| (images.len(), images.iter().map(String::len).sum::())), ) .finish() } } impl fmt::Debug for CanonicalEmbeddingRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CanonicalEmbeddingRequest") .field("input", &self.input) .field("encoding_format", &self.encoding_format) .field("dimensions", &self.dimensions) .field("task_len", &debug_string_len(self.task.as_deref())) .field("user_len", &debug_string_len(self.user.as_deref())) .field( "parameters_bytes", &self.parameters.as_ref().and_then(debug_json_map_bytes), ) .field("parameter_count", &self.parameters.as_ref().map(Map::len)) .field("extension_count", &self.extensions.len()) .finish() } } impl fmt::Debug for CanonicalRerankRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CanonicalRerankRequest") .field("query_len", &self.query.len()) .field("documents", &debug_json_list_summary(&self.documents)) .field("top_n", &self.top_n) .field("return_documents", &self.return_documents) .field("extension_count", &self.extensions.len()) .finish() } } impl fmt::Debug for CanonicalEmbedding { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CanonicalEmbedding") .field("index", &self.index) .field("embedding_len", &self.embedding.len()) .field("extension_count", &self.extensions.len()) .finish() } } impl fmt::Debug for CanonicalEmbeddingResponse { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CanonicalEmbeddingResponse") .field("id_len", &self.id.len()) .field("model_len", &self.model.len()) .field("embedding_count", &self.embeddings.len()) .field("usage", &self.usage) .field("extension_count", &self.extensions.len()) .finish() } } impl fmt::Debug for CanonicalRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CanonicalRequest") .field("model_len", &self.model.len()) .field("instruction_count", &self.instructions.len()) .field("system_len", &debug_string_len(self.system.as_deref())) .field("message_count", &self.messages.len()) .field("embedding", &self.embedding) .field("rerank", &self.rerank) .field("generation", &self.generation) .field("tool_count", &self.tools.len()) .field("tool_choice", &self.tool_choice) .field("thinking", &self.thinking) .field("response_format", &self.response_format) .field("parallel_tool_calls", &self.parallel_tool_calls) .field( "metadata_bytes", &debug_json_option_bytes(self.metadata.as_ref()), ) .field("extension_count", &self.extensions.len()) .finish() } } impl fmt::Debug for CanonicalResponseOutput { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CanonicalResponseOutput") .field("index", &self.index) .field("role", &self.role) .field("content_count", &self.content.len()) .field("stop_reason", &self.stop_reason) .field("extension_count", &self.extensions.len()) .finish() } } impl fmt::Debug for CanonicalResponse { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("CanonicalResponse") .field("id_len", &self.id.len()) .field("model_len", &self.model.len()) .field("output_count", &self.outputs.len()) .field("content_count", &self.content.len()) .field("stop_reason", &self.stop_reason) .field("usage", &self.usage) .field("extension_count", &self.extensions.len()) .finish() } } pub fn from_openai_chat_to_canonical_request(body_json: &Value) -> Option { crate::formats::openai::chat::request::from_raw(body_json) } pub fn canonical_to_openai_chat_request(canonical: &CanonicalRequest) -> Option { crate::formats::openai::chat::request::to( canonical, &crate::formats::context::FormatContext::default(), ) } pub fn from_openai_responses_to_canonical_request(body_json: &Value) -> Option { crate::formats::openai::responses::request::from_raw(body_json) } pub(crate) fn canonical_to_openai_responses_request_with_profile( canonical: &CanonicalRequest, mapped_model: &str, upstream_is_stream: bool, compact: bool, ) -> Option { crate::formats::openai::responses::request::to_raw( canonical, mapped_model, upstream_is_stream, compact, ) } pub fn canonical_to_openai_responses_request( canonical: &CanonicalRequest, mapped_model: &str, upstream_is_stream: bool, ) -> Option { canonical_to_openai_responses_request_with_profile( canonical, mapped_model, upstream_is_stream, false, ) } pub fn canonical_to_openai_responses_compact_request( canonical: &CanonicalRequest, mapped_model: &str, ) -> Option { canonical_to_openai_responses_request_with_profile(canonical, mapped_model, false, true) } pub fn from_claude_to_canonical_request(body_json: &Value) -> Option { crate::formats::claude::messages::request::from_raw(body_json) } pub fn canonical_to_claude_request( canonical: &CanonicalRequest, mapped_model: &str, upstream_is_stream: bool, ) -> Option { crate::formats::claude::messages::request::to_raw(canonical, mapped_model, upstream_is_stream) } pub fn from_gemini_to_canonical_request( body_json: &Value, request_path: &str, ) -> Option { crate::formats::gemini::generate_content::request::from_raw(body_json, request_path) } pub fn canonical_to_gemini_request( canonical: &CanonicalRequest, mapped_model: &str, upstream_is_stream: bool, ) -> Option { crate::formats::gemini::generate_content::request::to_raw( canonical, mapped_model, upstream_is_stream, ) } #[cfg(test)] pub(crate) fn from_embedding_to_canonical_request( body_json: &Value, namespace: &str, ) -> Option { match namespace { "openai" => crate::formats::openai::embedding::request::from_namespace(body_json, "openai"), "jina" => crate::formats::openai::embedding::request::from_namespace(body_json, "jina"), _ => None, } } #[cfg(test)] pub(crate) fn canonical_to_embedding_request( canonical: &CanonicalRequest, mapped_model: &str, namespace: &str, ) -> Option { let ctx = crate::formats::context::FormatContext::default().with_mapped_model(mapped_model); match namespace { "openai" => crate::formats::openai::embedding::request::to(canonical, &ctx), "jina" => crate::formats::jina::embedding::request::to(canonical, &ctx), "gemini" => crate::formats::gemini::embedding::request::to(canonical, &ctx), "doubao" => crate::formats::doubao::embedding::request::to(canonical, &ctx), "aliyun" => crate::formats::aliyun::embedding::request::to(canonical, &ctx), _ => None, } } pub fn from_openai_chat_to_canonical_response(body_json: &Value) -> Option { crate::formats::openai::chat::response::from_raw(body_json) } pub fn from_openai_responses_to_canonical_response(body_json: &Value) -> Option { crate::formats::openai::responses::response::from_raw(body_json) } pub fn from_claude_to_canonical_response(body_json: &Value) -> Option { crate::formats::claude::messages::response::from_raw(body_json) } pub fn from_gemini_to_canonical_response(body_json: &Value) -> Option { crate::formats::gemini::generate_content::response::from_raw(body_json) } pub fn canonical_to_openai_chat_response(canonical: &CanonicalResponse) -> Value { crate::formats::openai::chat::response::to_raw(canonical) } pub(crate) fn canonical_blocks_to_openai_chat_message(content: &[CanonicalContentBlock]) -> Value { let mut message = Map::new(); message.insert("role".to_string(), Value::String("assistant".to_string())); let mut visible_blocks = Vec::new(); let mut reasoning_text = Vec::new(); let mut reasoning_parts = Vec::new(); let mut tool_calls = Vec::new(); let mut annotations = Vec::new(); let mut refusal = Vec::new(); let mut audio = None; let mut text_offset = 0_i64; for block in content { match block { CanonicalContentBlock::Thinking { text, signature, encrypted_content, extensions, } => { if let Some(data) = encrypted_content.as_ref().filter(|value| !value.is_empty()) { reasoning_parts.push(json!({ "type": "redacted_thinking", "data": data, })); continue; } if !text.trim().is_empty() { let omit_reasoning_content = extensions .get("openai") .and_then(|value| value.get("omit_reasoning_content")) .and_then(Value::as_bool) .unwrap_or(false); let omit_reasoning_parts = extensions .get("openai") .and_then(|value| value.get("omit_reasoning_parts")) .and_then(Value::as_bool) .unwrap_or(false); if !omit_reasoning_content { reasoning_text.push(text.clone()); } if !omit_reasoning_parts { let mut reasoning_part = Map::new(); reasoning_part .insert("type".to_string(), Value::String("thinking".to_string())); reasoning_part.insert("thinking".to_string(), Value::String(text.clone())); if let Some(signature) = signature.as_ref().filter(|value| !value.is_empty()) { reasoning_part .insert("signature".to_string(), Value::String(signature.clone())); } reasoning_parts.push(Value::Object(reasoning_part)); } } } CanonicalContentBlock::ToolUse { id, name, input, extensions, } => tool_calls.push(canonical_tool_use_to_openai_chat_tool_call( id, name, input, extensions, )), CanonicalContentBlock::Audio { data, extensions, .. } if is_openai_output_audio_block(extensions) => { audio = canonical_audio_to_openai_chat_audio(data.as_deref(), extensions); } CanonicalContentBlock::Text { text, extensions } => { if let Some(raw_annotations) = extensions .get(OPENAI_RESPONSES_EXTENSION_NAMESPACE) .or_else(|| extensions.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)) .and_then(|value| value.get("annotations")) .and_then(Value::as_array) { annotations.extend(raw_annotations.iter().map(|annotation| { offset_openai_annotation_indices(annotation, text_offset) })); } text_offset += text.chars().count() as i64; if let Some(part) = canonical_content_block_to_openai_part(block) { visible_blocks.push(part); } } CanonicalContentBlock::Unknown { raw_type, payload, .. } if raw_type == "refusal" => { if let Some(text) = payload.get("refusal").and_then(Value::as_str) { if !text.trim().is_empty() { refusal.push(text.to_string()); } } } other => { if let Some(part) = canonical_content_block_to_openai_part(other) { if let Some(text) = part .as_object() .and_then(|object| object.get("text")) .and_then(Value::as_str) { text_offset += text.chars().count() as i64; } visible_blocks.push(part); } } } } if !reasoning_text.is_empty() { message.insert( "reasoning_content".to_string(), Value::String(reasoning_text.join("")), ); } if !reasoning_parts.is_empty() { message.insert("reasoning_parts".to_string(), Value::Array(reasoning_parts)); } if !tool_calls.is_empty() { message.insert("tool_calls".to_string(), Value::Array(tool_calls.clone())); } if !refusal.is_empty() { message.insert("refusal".to_string(), Value::String(refusal.join("\n"))); } if let Some(audio) = audio { message.insert("audio".to_string(), audio); } if !annotations.is_empty() { message.insert("annotations".to_string(), Value::Array(annotations)); } message.insert( "content".to_string(), openai_content_value_from_parts(visible_blocks, !tool_calls.is_empty()), ); Value::Object(message) } fn canonical_tool_use_to_openai_chat_tool_call( id: &str, name: &str, input: &Value, extensions: &BTreeMap, ) -> Value { if is_openai_custom_tool_call(extensions) { let mut custom = extensions .get("openai") .and_then(|value| value.get("custom")) .and_then(Value::as_object) .cloned() .unwrap_or_default(); custom.insert("name".to_string(), Value::String(name.to_string())); custom.insert( "input".to_string(), Value::String(openai_custom_tool_input_text(input)), ); return json!({ "id": id, "type": "custom", "custom": Value::Object(custom), }); } json!({ "id": id, "type": "function", "function": { "name": name, "arguments": canonicalize_tool_arguments(input), } }) } pub(crate) fn canonical_tool_use_to_openai_responses_item( id: &str, name: &str, input: &Value, extensions: &BTreeMap, ) -> Value { if let Some(item_type) = openai_responses_hosted_tool_call_item_type(extensions) { let mut item = Map::new(); item.insert("type".to_string(), Value::String(item_type.to_string())); item.insert("id".to_string(), Value::String(id.to_string())); item.insert("call_id".to_string(), Value::String(id.to_string())); item.insert("status".to_string(), Value::String("completed".to_string())); if let Some(input_object) = input.as_object() { for field in openai_responses_hosted_tool_input_fields(item_type) { if let Some(value) = input_object.get(*field) { item.insert((*field).to_string(), value.clone()); } } } else if item_type == "apply_patch_call" { item.insert("operation".to_string(), input.clone()); } else if !name.trim().is_empty() { item.insert("name".to_string(), Value::String(name.to_string())); } let extension_fields = openai_responses_item_extension_object(extensions, &item); item.extend(extension_fields); return Value::Object(item); } if is_openai_custom_tool_call(extensions) { let item_id = openai_responses_tool_call_item_id(id, extensions); let mut item = Map::new(); item.insert( "type".to_string(), Value::String("custom_tool_call".to_string()), ); item.insert("id".to_string(), Value::String(item_id)); item.insert("call_id".to_string(), Value::String(id.to_string())); item.insert("status".to_string(), Value::String("completed".to_string())); item.insert("name".to_string(), Value::String(name.to_string())); item.insert( "input".to_string(), Value::String(openai_custom_tool_input_text(input)), ); let extension_fields = openai_responses_item_extension_object(extensions, &item); item.extend(extension_fields); return Value::Object(item); } let item_id = openai_responses_tool_call_item_id(id, extensions); let mut item = Map::new(); item.insert( "type".to_string(), Value::String("function_call".to_string()), ); item.insert("id".to_string(), Value::String(item_id)); item.insert("call_id".to_string(), Value::String(id.to_string())); item.insert("name".to_string(), Value::String(name.to_string())); item.insert( "arguments".to_string(), Value::String(canonicalize_tool_arguments(input)), ); let extension_fields = openai_responses_item_extension_object(extensions, &item); item.extend(extension_fields); Value::Object(item) } pub(crate) fn canonical_tool_use_to_openai_responses_input_item( id: &str, name: &str, input: &Value, extensions: &BTreeMap, ) -> Value { if let Some(item_type) = openai_responses_hosted_tool_call_item_type(extensions) { let mut item = Map::new(); item.insert("type".to_string(), Value::String(item_type.to_string())); item.insert("id".to_string(), Value::String(id.to_string())); item.insert("call_id".to_string(), Value::String(id.to_string())); item.insert("status".to_string(), Value::String("completed".to_string())); if let Some(input_object) = input.as_object() { for field in openai_responses_hosted_tool_input_fields(item_type) { if let Some(value) = input_object.get(*field) { item.insert((*field).to_string(), value.clone()); } } } else if item_type == "apply_patch_call" { item.insert("operation".to_string(), input.clone()); } else if !name.trim().is_empty() { item.insert("name".to_string(), Value::String(name.to_string())); } let extension_fields = openai_responses_item_extension_object(extensions, &item); item.extend(extension_fields); return Value::Object(item); } if is_openai_custom_tool_call(extensions) { let mut item = Map::new(); item.insert( "type".to_string(), Value::String("custom_tool_call".to_string()), ); if let Some(item_id) = openai_responses_request_tool_call_item_id(extensions, "ctc") { item.insert("id".to_string(), Value::String(item_id)); } item.insert("call_id".to_string(), Value::String(id.to_string())); item.insert("status".to_string(), Value::String("completed".to_string())); item.insert("name".to_string(), Value::String(name.to_string())); item.insert( "input".to_string(), Value::String(openai_custom_tool_input_text(input)), ); let extension_fields = openai_responses_item_extension_object(extensions, &item); item.extend(extension_fields); return Value::Object(item); } let mut item = Map::new(); item.insert( "type".to_string(), Value::String("function_call".to_string()), ); if let Some(item_id) = openai_responses_request_tool_call_item_id(extensions, "fc") { item.insert("id".to_string(), Value::String(item_id)); } item.insert("call_id".to_string(), Value::String(id.to_string())); item.insert("name".to_string(), Value::String(name.to_string())); item.insert( "arguments".to_string(), Value::String(canonicalize_tool_arguments(input)), ); let extension_fields = openai_responses_item_extension_object(extensions, &item); item.extend(extension_fields); Value::Object(item) } fn openai_responses_tool_call_item_id( call_id: &str, extensions: &BTreeMap, ) -> String { if let Some(item_id) = openai_responses_extension(extensions) .and_then(|value| value.get("item_id").or_else(|| value.get("id"))) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { return item_id.to_string(); } let trimmed = call_id.trim(); if trimmed.is_empty() { "call_auto".to_string() } else { trimmed.to_string() } } fn openai_responses_request_tool_call_item_id( extensions: &BTreeMap, prefix: &str, ) -> Option { openai_responses_extension(extensions) .and_then(|value| value.get("item_id").or_else(|| value.get("id"))) .and_then(Value::as_str) .map(str::trim) .filter(|value| value.starts_with(prefix)) .map(ToString::to_string) } fn openai_responses_hosted_tool_call_item_type( extensions: &BTreeMap, ) -> Option<&str> { let item_type = openai_responses_extension(extensions) .and_then(|value| value.get("item_type")) .and_then(Value::as_str)?; openai_responses_hosted_tool_name(item_type).map(|_| item_type) } fn openai_responses_hosted_tool_input_fields(item_type: &str) -> &'static [&'static str] { match item_type { "local_shell_call" | "shell_call" => &[ "action", "environment", "status", "created_by", "max_output_length", ], "apply_patch_call" => &["operation", "status"], "computer_call" => &["action", "actions", "pending_safety_checks", "status"], _ => &[], } } fn openai_custom_tool_input_text(input: &Value) -> String { match input { Value::String(text) => text.clone(), Value::Null => String::new(), value => value.to_string(), } } fn canonical_audio_to_openai_chat_audio( data: Option<&str>, extensions: &BTreeMap, ) -> Option { let mut audio = extensions .get("openai") .and_then(|value| value.get("audio")) .and_then(Value::as_object) .cloned() .unwrap_or_default(); if let Some(data) = data.filter(|value| !value.is_empty()) { audio.insert("data".to_string(), Value::String(data.to_string())); } if !audio.contains_key("transcript") { if let Some(transcript) = openai_responses_extension(extensions) .and_then(|value| value.get("transcript")) .cloned() { audio.insert("transcript".to_string(), transcript); } } (!audio.is_empty()).then_some(Value::Object(audio)) } pub(crate) fn canonical_to_openai_responses_response_with_profile( canonical: &CanonicalResponse, report_context: &Value, compact: bool, ) -> Value { crate::formats::openai::responses::response::to_raw(canonical, report_context, compact) } pub fn canonical_to_openai_responses_response( canonical: &CanonicalResponse, report_context: &Value, ) -> Value { canonical_to_openai_responses_response_with_profile(canonical, report_context, false) } pub fn canonical_to_openai_responses_compact_response( canonical: &CanonicalResponse, report_context: &Value, ) -> Value { canonical_to_openai_responses_response_with_profile(canonical, report_context, true) } pub fn canonical_to_claude_response(canonical: &CanonicalResponse) -> Value { crate::formats::claude::messages::response::to_raw(canonical) } pub fn canonical_to_gemini_response( canonical: &CanonicalResponse, report_context: &Value, ) -> Option { crate::formats::gemini::generate_content::response::to_raw(canonical, report_context) } pub fn from_embedding_to_canonical_response( body_json: &Value, namespace: &str, ) -> Option { match namespace { "openai" => { crate::formats::openai::embedding::response::from_namespace(body_json, "openai") } "jina" => crate::formats::openai::embedding::response::from_namespace(body_json, "jina"), "gemini" => crate::formats::gemini::embedding::response::from(body_json), "aliyun" => crate::formats::aliyun::embedding::response::from(body_json), _ => None, } } pub fn canonical_to_embedding_response( canonical: &CanonicalEmbeddingResponse, namespace: &str, ) -> Option { match namespace { "openai" => crate::formats::openai::embedding::response::to(canonical), "jina" => crate::formats::jina::embedding::response::to(canonical), _ => None, } } pub fn canonical_unknown_block_count(blocks: &[CanonicalContentBlock]) -> usize { blocks .iter() .filter(|block| matches!(block, CanonicalContentBlock::Unknown { .. })) .count() } pub fn canonical_request_unknown_block_count(request: &CanonicalRequest) -> usize { request .messages .iter() .map(|message| canonical_unknown_block_count(&message.content)) .sum() } pub fn canonical_response_unknown_block_count(response: &CanonicalResponse) -> usize { canonical_unknown_block_count(&response.content) } pub(crate) fn openai_role_to_canonical(role: &str) -> CanonicalRole { match role.trim().to_ascii_lowercase().as_str() { "user" => CanonicalRole::User, "assistant" => CanonicalRole::Assistant, "system" => CanonicalRole::System, "developer" => CanonicalRole::Developer, "tool" | "function" => CanonicalRole::Tool, _ => CanonicalRole::Unknown, } } pub(crate) fn gemini_system_to_canonical_instructions( system_instruction: Option<&Value>, ) -> Option> { let Some(system_instruction) = system_instruction else { return Some(Vec::new()); }; match system_instruction { Value::String(text) => { if text.trim().is_empty() { Some(Vec::new()) } else { Some(vec![CanonicalInstruction { role: CanonicalRole::System, text: text.clone(), extensions: BTreeMap::new(), }]) } } Value::Object(object) => { let parts = object.get("parts").and_then(Value::as_array)?; let mut instructions = Vec::new(); for part in parts { let part = part.as_object()?; let text = part.get("text").and_then(Value::as_str).unwrap_or_default(); if text.trim().is_empty() { continue; } instructions.push(CanonicalInstruction { role: CanonicalRole::System, text: text.to_string(), extensions: gemini_extensions(part, &["text"]), }); } Some(instructions) } _ => None, } } pub(crate) fn gemini_contents_to_canonical_messages( contents: Option<&Value>, ) -> Option> { let Some(contents) = contents else { return Some(Vec::new()); }; let contents = contents.as_array()?; let mut messages = Vec::new(); let mut reserved_tool_call_ids = contents .iter() .filter_map(Value::as_object) .filter_map(|content| content.get("parts")) .filter_map(Value::as_array) .flatten() .filter_map(Value::as_object) .filter_map(|part| { part.get("functionCall") .or_else(|| part.get("function_call")) .or_else(|| part.get("functionResponse")) .or_else(|| part.get("function_response")) .and_then(Value::as_object) .and_then(gemini_explicit_function_id) .map(ToOwned::to_owned) }) .collect::>(); let mut pending_tool_calls = VecDeque::<(String, String)>::new(); let mut next_generated_tool_call_index = 0usize; for content in contents { let content_object = content.as_object()?; let role = match content_object .get("role") .and_then(Value::as_str) .unwrap_or("user") .trim() .to_ascii_lowercase() .as_str() { "model" => CanonicalRole::Assistant, "system" => CanonicalRole::System, "tool" | "function" => CanonicalRole::Tool, _ => CanonicalRole::User, }; let parts = content_object.get("parts").and_then(Value::as_array)?; let mut blocks = Vec::new(); for (index, part) in parts.iter().enumerate() { let mut block = gemini_part_to_canonical_block(part, index)?; match &mut block { CanonicalContentBlock::ToolUse { id, name, .. } => { let has_explicit_id = part .as_object() .and_then(|part| { part.get("functionCall") .or_else(|| part.get("function_call")) }) .and_then(Value::as_object) .and_then(gemini_explicit_function_id) .is_some(); if !has_explicit_id { loop { let generated = format!("call_auto_{next_generated_tool_call_index}"); next_generated_tool_call_index += 1; if reserved_tool_call_ids.insert(generated.clone()) { *id = generated; break; } } } pending_tool_calls.push_back((id.clone(), name.clone())); } CanonicalContentBlock::ToolResult { tool_use_id, name, .. } => { let explicit_response_id = part .as_object() .and_then(|part| { part.get("functionResponse") .or_else(|| part.get("function_response")) }) .and_then(Value::as_object) .and_then(gemini_explicit_function_id); let matched_position = explicit_response_id .and_then(|response_id| { pending_tool_calls .iter() .position(|(call_id, _)| call_id == response_id) }) .or_else(|| { if explicit_response_id.is_none() { name.as_deref().and_then(|response_name| { pending_tool_calls .iter() .position(|(_, call_name)| call_name == response_name) }) } else { None } }); let matched_call = matched_position .and_then(|position| pending_tool_calls.remove(position)) .or_else(|| { if explicit_response_id.is_none() && name.is_none() { pending_tool_calls.pop_front() } else { None } }); if let Some((call_id, call_name)) = matched_call { *tool_use_id = call_id; if name.is_none() { *name = Some(call_name); } } } _ => {} } blocks.push(block); } if blocks.is_empty() { continue; } messages.push(CanonicalMessage { role, content: blocks, extensions: gemini_extensions(content_object, &["role", "parts"]), }); } Some(messages) } pub(crate) fn gemini_part_to_canonical_block( part: &Value, index: usize, ) -> Option { let part_object = part.as_object()?; if let Some(text) = part_object.get("text").and_then(Value::as_str) { let thought_signature = part_object .get("thoughtSignature") .or_else(|| part_object.get("thought_signature")) .and_then(Value::as_str) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let is_thinking = part_object .get("thought") .and_then(Value::as_bool) .unwrap_or(false) || (text.trim().is_empty() && thought_signature.is_some()); if is_thinking { return Some(CanonicalContentBlock::Thinking { text: text.to_string(), signature: thought_signature, encrypted_content: None, extensions: gemini_extensions( part_object, &["text", "thought", "thoughtSignature", "thought_signature"], ), }); } return Some(CanonicalContentBlock::Text { text: text.to_string(), extensions: gemini_extensions(part_object, &["text"]), }); } if let Some(inline_data) = part_object .get("inlineData") .or_else(|| part_object.get("inline_data")) .and_then(Value::as_object) { return gemini_inline_data_to_canonical_block(inline_data, part_object); } if let Some(file_data) = part_object .get("fileData") .or_else(|| part_object.get("file_data")) .and_then(Value::as_object) { return gemini_file_data_to_canonical_block(file_data, part_object); } if let Some(function_call) = part_object .get("functionCall") .or_else(|| part_object.get("function_call")) .and_then(Value::as_object) { let name = function_call .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; let id = gemini_explicit_function_id(function_call) .map(ToOwned::to_owned) .unwrap_or_else(|| format!("call_auto_{index}")); return Some(CanonicalContentBlock::ToolUse { id, name: name.to_string(), input: function_call .get("args") .cloned() .unwrap_or_else(|| json!({})), extensions: gemini_extensions(part_object, &["functionCall", "function_call"]), }); } if let Some(function_response) = part_object .get("functionResponse") .or_else(|| part_object.get("function_response")) .and_then(Value::as_object) { let name = function_response .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let tool_use_id = gemini_explicit_function_id(function_response) .map(ToOwned::to_owned) .or_else(|| name.clone()) .unwrap_or_else(|| format!("toolu_response_{index}")); let response = function_response .get("response") .cloned() .unwrap_or_else(|| json!({})); let output = match response { Value::Object(mut object) => object .remove("result") .unwrap_or_else(|| Value::Object(object)), other => other, }; return Some(CanonicalContentBlock::ToolResult { tool_use_id, name, output: Some(output.clone()), content_text: Some(openai_responses_tool_output_text(&output)), is_error: false, extensions: gemini_extensions(part_object, &["functionResponse", "function_response"]), }); } Some(CanonicalContentBlock::Unknown { raw_type: gemini_raw_part_type(part_object), payload: part.clone(), extensions: BTreeMap::from([("gemini".to_string(), part.clone())]), }) } fn gemini_explicit_function_id(function: &Map) -> Option<&str> { ["id", "call_id", "callId"].iter().find_map(|field| { function .get(*field) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) }) } pub(crate) fn gemini_inline_data_to_canonical_block( inline_data: &Map, part_object: &Map, ) -> Option { let media_type = inline_data .get("mimeType") .or_else(|| inline_data.get("mime_type")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())? .to_string(); let data = inline_data .get("data") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())? .to_string(); if media_type.starts_with("image/") { return Some(CanonicalContentBlock::Image { data: Some(data), url: None, media_type: Some(media_type), detail: None, extensions: gemini_extensions(part_object, &["inlineData", "inline_data"]), }); } if let Some(format) = media_type.strip_prefix("audio/") { return Some(CanonicalContentBlock::Audio { data: Some(data), media_type: Some(media_type.clone()), format: Some(format.to_string()), extensions: gemini_extensions(part_object, &["inlineData", "inline_data"]), }); } Some(CanonicalContentBlock::File { data: Some(data), file_id: None, file_url: None, media_type: Some(media_type), filename: None, extensions: gemini_extensions(part_object, &["inlineData", "inline_data"]), }) } pub(crate) fn gemini_file_data_to_canonical_block( file_data: &Map, part_object: &Map, ) -> Option { let file_uri = file_data .get("fileUri") .or_else(|| file_data.get("file_uri")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())? .to_string(); let media_type = file_data .get("mimeType") .or_else(|| file_data.get("mime_type")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); if media_type .as_deref() .is_some_and(|value| value.starts_with("image/")) { return Some(CanonicalContentBlock::Image { data: None, url: Some(file_uri), media_type, detail: None, extensions: gemini_extensions(part_object, &["fileData", "file_data"]), }); } Some(CanonicalContentBlock::File { data: None, file_id: None, file_url: Some(file_uri), media_type, filename: None, extensions: gemini_extensions(part_object, &["fileData", "file_data"]), }) } pub(crate) fn gemini_raw_part_type(part: &Map) -> String { for key in [ "executableCode", "executable_code", "codeExecutionResult", "code_execution_result", "videoMetadata", "video_metadata", ] { if part.contains_key(key) { return key.to_string(); } } "unknown".to_string() } pub(crate) fn claude_system_to_canonical_instructions( system: Option<&Value>, ) -> Option> { let Some(system) = system else { return Some(Vec::new()); }; match system { Value::String(text) => { let text = strip_claude_billing_header(text); if text.trim().is_empty() { Some(Vec::new()) } else { Some(vec![CanonicalInstruction { role: CanonicalRole::System, text, extensions: claude_system_instruction_extensions(BTreeMap::new()), }]) } } Value::Array(blocks) => { let mut instructions = Vec::new(); for block in blocks { let block = block.as_object()?; if block.get("type").and_then(Value::as_str).unwrap_or("text") != "text" { continue; } let text = block .get("text") .and_then(Value::as_str) .unwrap_or_default(); if !text.trim().is_empty() { instructions.push(CanonicalInstruction { role: CanonicalRole::System, text: strip_claude_billing_header(text), extensions: claude_system_instruction_extensions(claude_extensions( block, &["type", "text"], )), }); } } Some(instructions) } _ => None, } } fn claude_system_instruction_extensions( mut extensions: BTreeMap, ) -> BTreeMap { canonical_extension_object_mut(&mut extensions, AETHER_EXTENSION_NAMESPACE).insert( "source".to_string(), Value::String(CLAUDE_SYSTEM_SOURCE_MARKER.to_string()), ); extensions } pub(crate) fn mark_claude_messages_request_source(extensions: &mut BTreeMap) { canonical_extension_object_mut(extensions, AETHER_EXTENSION_NAMESPACE).insert( "source".to_string(), Value::String(CLAUDE_MESSAGES_REQUEST_SOURCE_MARKER.to_string()), ); } pub(crate) fn is_claude_messages_request(extensions: &BTreeMap) -> bool { extensions .get(AETHER_EXTENSION_NAMESPACE) .and_then(|value| value.get("source")) .and_then(Value::as_str) == Some(CLAUDE_MESSAGES_REQUEST_SOURCE_MARKER) } pub(crate) fn is_claude_system_instruction(instruction: &CanonicalInstruction) -> bool { instruction .extensions .get(AETHER_EXTENSION_NAMESPACE) .and_then(|value| value.get("source")) .and_then(Value::as_str) == Some(CLAUDE_SYSTEM_SOURCE_MARKER) } pub(crate) fn claude_messages_to_canonical( messages: Option<&Value>, ) -> Option> { let Some(messages) = messages else { return Some(Vec::new()); }; let messages = messages.as_array()?; messages .iter() .map(|message| { let message = message.as_object()?; let role = openai_role_to_canonical( message .get("role") .and_then(Value::as_str) .unwrap_or_default(), ); Some(CanonicalMessage { role, content: claude_content_to_canonical_blocks(message.get("content"))?, extensions: claude_extensions(message, &["role", "content"]), }) }) .collect() } pub(crate) fn claude_content_to_canonical_blocks( content: Option<&Value>, ) -> Option> { let Some(content) = content else { return Some(Vec::new()); }; match content { Value::Null => Some(Vec::new()), Value::String(text) => { if text.is_empty() { Some(Vec::new()) } else { Some(vec![CanonicalContentBlock::Text { text: text.clone(), extensions: BTreeMap::new(), }]) } } Value::Array(blocks) => { let mut canonical = Vec::new(); let mut next_generated_tool_use_index = 0usize; for block in blocks { let mut canonical_block = claude_block_to_canonical_block(block)?; if let CanonicalContentBlock::ToolUse { id, .. } = &mut canonical_block { if id.trim().is_empty() { *id = format!("toolu_auto_{next_generated_tool_use_index}"); next_generated_tool_use_index += 1; } } canonical.push(canonical_block); } Some(canonical) } _ => None, } } pub(crate) fn claude_block_to_canonical_block(block: &Value) -> Option { let block_object = block.as_object()?; let raw_type = block_object .get("type") .and_then(Value::as_str) .unwrap_or_default() .trim() .to_ascii_lowercase(); match raw_type.as_str() { "text" => Some(CanonicalContentBlock::Text { text: block_object .get("text") .and_then(Value::as_str) .unwrap_or_default() .to_string(), extensions: claude_extensions(block_object, &["type", "text"]), }), "thinking" => Some(CanonicalContentBlock::Thinking { text: block_object .get("thinking") .or_else(|| block_object.get("text")) .and_then(Value::as_str) .unwrap_or_default() .to_string(), signature: block_object .get("signature") .and_then(Value::as_str) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), encrypted_content: None, extensions: claude_thinking_extensions(claude_extensions( block_object, &["type", "thinking", "text", "signature"], )), }), "redacted_thinking" => Some(CanonicalContentBlock::Thinking { text: String::new(), signature: None, encrypted_content: block_object .get("data") .and_then(Value::as_str) .map(ToOwned::to_owned), extensions: claude_thinking_extensions(claude_extensions( block_object, &["type", "data"], )), }), "image" => claude_media_block_to_canonical(block_object, true), "document" => claude_media_block_to_canonical(block_object, false), "tool_use" => Some(CanonicalContentBlock::ToolUse { id: block_object .get("id") .and_then(Value::as_str) .unwrap_or_default() .to_string(), name: block_object .get("name") .and_then(Value::as_str) .unwrap_or_default() .to_string(), input: block_object .get("input") .cloned() .unwrap_or_else(|| json!({})), extensions: claude_extensions(block_object, &["type", "id", "name", "input"]), }), "tool_result" => { let content = block_object.get("content").cloned(); let mut extensions = claude_extensions( block_object, &["type", "tool_use_id", "content", "is_error"], ); extensions.insert( AETHER_EXTENSION_NAMESPACE.to_string(), json!({ "source": CLAUDE_TOOL_RESULT_SOURCE_MARKER }), ); Some(CanonicalContentBlock::ToolResult { tool_use_id: block_object .get("tool_use_id") .and_then(Value::as_str) .unwrap_or_default() .to_string(), name: None, output: content.clone(), content_text: content.as_ref().map(openai_responses_tool_output_text), is_error: block_object .get("is_error") .and_then(Value::as_bool) .unwrap_or(false), extensions, }) } _ => Some(CanonicalContentBlock::Unknown { raw_type, payload: block.clone(), extensions: claude_raw_extensions(BTreeMap::new()), }), } } pub(crate) fn claude_media_block_to_canonical( block: &Map, image: bool, ) -> Option { let source = block.get("source")?.as_object()?; let source_type = source .get("type") .and_then(Value::as_str) .unwrap_or_default(); let media_type = source .get("media_type") .and_then(Value::as_str) .map(ToOwned::to_owned); match (image, source_type) { (true, "base64") => Some(CanonicalContentBlock::Image { data: source .get("data") .and_then(Value::as_str) .map(ToOwned::to_owned), url: None, media_type, detail: None, extensions: claude_extensions(block, &["type", "source"]), }), (true, "url") => Some(CanonicalContentBlock::Image { data: None, url: source .get("url") .and_then(Value::as_str) .map(ToOwned::to_owned), media_type: None, detail: None, extensions: claude_extensions(block, &["type", "source"]), }), (false, "base64") if media_type .as_deref() .is_some_and(|value| value.starts_with("audio/")) => { let format = media_type .as_deref() .and_then(|value| value.strip_prefix("audio/")) .map(ToOwned::to_owned); Some(CanonicalContentBlock::Audio { data: source .get("data") .and_then(Value::as_str) .map(ToOwned::to_owned), media_type, format, extensions: claude_extensions(block, &["type", "source"]), }) } (false, "base64") => Some(CanonicalContentBlock::File { data: source .get("data") .and_then(Value::as_str) .map(ToOwned::to_owned), file_id: None, file_url: None, media_type, filename: None, extensions: claude_extensions(block, &["type", "source"]), }), (false, "url") => Some(CanonicalContentBlock::File { data: None, file_id: None, file_url: source .get("url") .and_then(Value::as_str) .map(ToOwned::to_owned), media_type: None, filename: None, extensions: claude_extensions(block, &["type", "source"]), }), _ => Some(CanonicalContentBlock::Unknown { raw_type: block .get("type") .and_then(Value::as_str) .unwrap_or_default() .to_string(), payload: Value::Object(block.clone()), extensions: claude_raw_extensions(BTreeMap::new()), }), } } pub(crate) fn openai_message_content_blocks( message: &Map, ) -> Option> { let role = openai_role_to_canonical(message.get("role").and_then(Value::as_str).unwrap_or("")); let mut blocks = if role == CanonicalRole::Tool { Vec::new() } else { openai_content_to_blocks(message.get("content"))? }; if role == CanonicalRole::Assistant { let reasoning_blocks = openai_reasoning_blocks(message); if !reasoning_blocks.is_empty() { blocks.splice(0..0, reasoning_blocks); } else if let Some(reasoning_content) = message .get("reasoning_content") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) { let mut extensions = BTreeMap::new(); canonical_extension_object_mut(&mut extensions, "openai") .insert("omit_reasoning_parts".to_string(), Value::Bool(true)); let extensions = openai_thinking_extensions(extensions); blocks.insert( 0, CanonicalContentBlock::Thinking { text: reasoning_content.to_string(), signature: None, encrypted_content: None, extensions, }, ); } if let Some(audio_object) = message.get("audio").and_then(Value::as_object) { blocks.push(openai_chat_audio_to_canonical_block(audio_object)); } } let mut saw_tool_calls = false; if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) { for tool_call in tool_calls { let tool_call = tool_call.as_object()?; let tool_call_type = tool_call .get("type") .and_then(Value::as_str) .unwrap_or("function") .trim() .to_ascii_lowercase(); saw_tool_calls = true; let id = tool_call .get("id") .and_then(Value::as_str) .unwrap_or_default() .to_string(); if tool_call_type == "custom" { let custom = tool_call.get("custom").and_then(Value::as_object)?; let name = custom .get("name") .and_then(Value::as_str) .unwrap_or("custom_tool") .to_string(); let mut extensions = openai_extensions(tool_call, &["id", "type"]); mark_openai_custom_tool_call(&mut extensions); blocks.push(CanonicalContentBlock::ToolUse { id, name, input: parse_jsonish_value( custom.get("input").or_else(|| custom.get("arguments")), ), extensions, }); } else { let function = tool_call.get("function").and_then(Value::as_object)?; blocks.push(CanonicalContentBlock::ToolUse { id, name: function .get("name") .and_then(Value::as_str) .unwrap_or_default() .to_string(), input: parse_jsonish_value(function.get("arguments")), extensions: openai_extensions(tool_call, &["id", "type", "function"]), }); } } } if role == CanonicalRole::Assistant && !saw_tool_calls { if let Some(function_call) = message.get("function_call").and_then(Value::as_object) { let name = function_call .get("name") .and_then(Value::as_str) .unwrap_or_default() .to_string(); blocks.push(CanonicalContentBlock::ToolUse { id: message .get("id") .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) .unwrap_or(name.as_str()) .to_string(), name, input: parse_jsonish_value(function_call.get("arguments")), extensions: openai_extensions(message, &["role", "content", "function_call"]), }); } } if role == CanonicalRole::Tool { let text = openai_content_text(message.get("content")); let mut extensions = openai_extensions(message, &["role", "content", "tool_call_id"]); extensions.insert( AETHER_EXTENSION_NAMESPACE.to_string(), json!({ "source": OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER }), ); blocks.push(CanonicalContentBlock::ToolResult { tool_use_id: message .get("tool_call_id") .and_then(Value::as_str) .or_else(|| message.get("name").and_then(Value::as_str)) .unwrap_or_default() .to_string(), name: None, output: message.get("content").cloned(), content_text: Some(if text.is_empty() { message .get("content") .map(openai_responses_tool_output_text) .unwrap_or_default() } else { text }), is_error: false, extensions, }); } Some(blocks) } fn openai_chat_audio_to_canonical_block( audio_object: &Map, ) -> CanonicalContentBlock { let mut extensions = BTreeMap::from([( "openai".to_string(), json!({ "audio": Value::Object(audio_object.clone()) }), )]); mark_openai_output_audio(&mut extensions); CanonicalContentBlock::Audio { data: audio_object .get("data") .and_then(Value::as_str) .map(ToOwned::to_owned), media_type: None, format: None, extensions, } } pub(crate) fn openai_reasoning_blocks(message: &Map) -> Vec { let Some(reasoning_parts) = message.get("reasoning_parts").and_then(Value::as_array) else { return Vec::new(); }; let omit_reasoning_content = message.get("reasoning_content").is_none(); let mut blocks = Vec::new(); for part in reasoning_parts { let Some(part_object) = part.as_object() else { continue; }; match part_object .get("type") .and_then(Value::as_str) .unwrap_or("thinking") { "thinking" => { let text = part_object .get("thinking") .or_else(|| part_object.get("text")) .and_then(Value::as_str) .unwrap_or_default(); if text.trim().is_empty() { continue; } let mut extensions = openai_extensions(part_object, &["type", "thinking", "text", "signature"]); if omit_reasoning_content { canonical_extension_object_mut(&mut extensions, "openai") .insert("omit_reasoning_content".to_string(), Value::Bool(true)); } let extensions = openai_thinking_extensions(extensions); blocks.push(CanonicalContentBlock::Thinking { text: text.to_string(), signature: part_object .get("signature") .and_then(Value::as_str) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned), encrypted_content: None, extensions, }); } "redacted_thinking" => { if let Some(data) = part_object .get("data") .and_then(Value::as_str) .filter(|value| !value.is_empty()) { let extensions = openai_thinking_extensions(openai_extensions( part_object, &["type", "data"], )); blocks.push(CanonicalContentBlock::Thinking { text: String::new(), signature: None, encrypted_content: Some(data.to_string()), extensions, }); } } _ => {} } } blocks } pub(crate) fn openai_responses_input_to_canonical_messages( input: Option<&Value>, ) -> Option> { let Some(input) = input else { return Some(Vec::new()); }; match input { Value::Null => Some(Vec::new()), Value::String(text) => { if text.trim().is_empty() { Some(Vec::new()) } else { Some(vec![CanonicalMessage { role: CanonicalRole::User, content: vec![CanonicalContentBlock::Text { text: text.clone(), extensions: BTreeMap::new(), }], extensions: BTreeMap::new(), }]) } } Value::Array(items) => { let mut messages = Vec::new(); let mut next_generated_tool_call_index = 0usize; let mut pending_reasoning: Option = None; for item in items { if let Some(text) = item.as_str() { if !text.trim().is_empty() { messages.push(CanonicalMessage { role: CanonicalRole::User, content: vec![CanonicalContentBlock::Text { text: text.to_string(), extensions: BTreeMap::new(), }], extensions: BTreeMap::new(), }); } pending_reasoning = None; continue; } let Some(item_object) = item.as_object() else { messages.push(openai_responses_opaque_input_item_message( item, String::new(), )); pending_reasoning = None; continue; }; let item_type = item_object .get("type") .and_then(Value::as_str) .unwrap_or("message") .trim() .to_ascii_lowercase(); match item_type.as_str() { "reasoning" => { let reasoning = openai_responses_reasoning_block_from_item(item_object); let previous_signature = reasoning.as_ref().and_then(|block| match block { CanonicalContentBlock::Thinking { text, encrypted_content: Some(carrier), .. } if text.trim().is_empty() => decode_gemini_tool_signature_carrier( carrier, ) .and_then(|(signature, direction)| { (direction == GeminiToolSignatureCarrierDirection::Previous) .then_some(signature) }), _ => None, }); if let Some(signature) = previous_signature { if attach_gemini_signature_to_previous_tool_use( &mut messages, signature, ) { pending_reasoning = None; continue; } } pending_reasoning = reasoning; } "message" => { let role = openai_role_to_canonical( item_object .get("role") .and_then(Value::as_str) .unwrap_or("user"), ); let is_assistant = role == CanonicalRole::Assistant; let mut extensions = openai_responses_extensions(item_object, &["type", "role", "content"]); mark_openai_responses_input_message(&mut extensions); let mut message = CanonicalMessage { role, content: openai_responses_content_to_blocks( item_object.get("content"), )?, extensions, }; if is_assistant { if let Some(reasoning) = pending_reasoning.take() { prepend_openai_responses_reasoning_block(&mut message, reasoning); } } else { pending_reasoning = None; } messages.push(message); } "function_call" => { let name = item_object .get("name") .and_then(Value::as_str) .map(str::trim) .unwrap_or_default() .to_string(); let id = item_object .get("call_id") .or_else(|| item_object.get("id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| { let generated = format!("call_auto_{next_generated_tool_call_index}"); next_generated_tool_call_index += 1; generated }); let mut extensions = openai_responses_extensions( item_object, &["type", "call_id", "id", "name", "arguments"], ); remember_openai_responses_tool_call_item_id(&mut extensions, item_object); let tool_use = CanonicalContentBlock::ToolUse { id, name, input: parse_jsonish_value(item_object.get("arguments")), extensions, }; append_openai_responses_tool_use( &mut messages, tool_use, &mut pending_reasoning, ); } "custom_tool_call" => { let name = item_object .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("custom_tool") .to_string(); let id = item_object .get("call_id") .or_else(|| item_object.get("id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| { let generated = format!("call_auto_{next_generated_tool_call_index}"); next_generated_tool_call_index += 1; generated }); let mut extensions = openai_responses_extensions( item_object, &[ "type", "call_id", "id", "name", "input", "arguments", "status", ], ); remember_openai_responses_tool_call_item_id(&mut extensions, item_object); mark_openai_custom_tool_call(&mut extensions); let tool_use = CanonicalContentBlock::ToolUse { id, name, input: parse_jsonish_value( item_object .get("input") .or_else(|| item_object.get("arguments")), ), extensions, }; append_openai_responses_tool_use( &mut messages, tool_use, &mut pending_reasoning, ); } "function_call_output" => { let id = item_object .get("call_id") .or_else(|| item_object.get("tool_call_id")) .or_else(|| item_object.get("id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| { let generated = format!("call_auto_{next_generated_tool_call_index}"); next_generated_tool_call_index += 1; generated }); let raw_output = item_object.get("output"); let output = Some(raw_output.cloned().unwrap_or_else(|| json!({}))); let mut extensions = openai_responses_extensions( item_object, &[ "type", "call_id", "tool_call_id", "id", "output", "is_error", ], ); extensions.insert( AETHER_EXTENSION_NAMESPACE.to_string(), json!({ "source": OPENAI_RESPONSES_TOOL_RESULT_SOURCE_MARKER }), ); messages.push(CanonicalMessage { role: CanonicalRole::Tool, content: vec![CanonicalContentBlock::ToolResult { tool_use_id: id, name: None, content_text: raw_output.map(openai_responses_tool_output_text), output, is_error: item_object .get("is_error") .and_then(Value::as_bool) .unwrap_or(false), extensions, }], extensions: BTreeMap::new(), }); pending_reasoning = None; } "custom_tool_call_output" | "local_shell_call_output" | "shell_call_output" | "apply_patch_call_output" | "computer_call_output" => { messages.push(CanonicalMessage { role: CanonicalRole::Tool, content: vec![openai_responses_hosted_tool_result_to_block( item_object, item_type.as_str(), next_generated_tool_call_index, )], extensions: BTreeMap::new(), }); pending_reasoning = None; } _ => { messages.push(openai_responses_opaque_input_item_message(item, item_type)); pending_reasoning = None; } } } Some(messages) } Value::Object(_) => { openai_responses_input_to_canonical_messages(Some(&Value::Array(vec![input.clone()]))) } _ => None, } } fn openai_responses_opaque_input_item_message(item: &Value, raw_type: String) -> CanonicalMessage { CanonicalMessage { role: CanonicalRole::Unknown, content: vec![CanonicalContentBlock::Unknown { raw_type, payload: item.clone(), extensions: openai_responses_raw_extensions(BTreeMap::new()), }], extensions: BTreeMap::new(), } } fn append_openai_responses_tool_use( messages: &mut Vec, mut tool_use: CanonicalContentBlock, pending_reasoning: &mut Option, ) { let mut reasoning = pending_reasoning.take(); if let Some(CanonicalContentBlock::Thinking { text, encrypted_content: Some(carrier), .. }) = reasoning.as_ref() { if text.trim().is_empty() { if let Some((signature, GeminiToolSignatureCarrierDirection::Next)) = decode_gemini_tool_signature_carrier(carrier) { if let CanonicalContentBlock::ToolUse { extensions, .. } = &mut tool_use { canonical_extension_object_mut(extensions, "gemini") .insert("thoughtSignature".to_string(), Value::String(signature)); reasoning = None; } } } } if let Some(last_message) = messages.last_mut() { if last_message.role == CanonicalRole::Assistant && (!is_openai_responses_input_message(&last_message.extensions) || canonical_assistant_message_has_visible_content(last_message)) { if let Some(reasoning) = reasoning { prepend_openai_responses_reasoning_block(last_message, reasoning); } last_message.content.push(tool_use); return; } } let mut content = Vec::new(); if let Some(reasoning) = reasoning { content.push(reasoning); } content.push(tool_use); messages.push(CanonicalMessage { role: CanonicalRole::Assistant, content, extensions: BTreeMap::new(), }); } fn attach_gemini_signature_to_previous_tool_use( messages: &mut [CanonicalMessage], signature: String, ) -> bool { let Some(message) = messages.last_mut() else { return false; }; if message.role != CanonicalRole::Assistant { return false; } let Some(CanonicalContentBlock::ToolUse { extensions, .. }) = message.content.last_mut() else { return false; }; canonical_extension_object_mut(extensions, "gemini") .insert("thoughtSignature".to_string(), Value::String(signature)); true } fn canonical_assistant_message_has_visible_content(message: &CanonicalMessage) -> bool { message.content.iter().any(|block| match block { CanonicalContentBlock::Text { text, .. } | CanonicalContentBlock::Thinking { text, .. } => { !text.trim().is_empty() } CanonicalContentBlock::Unknown { payload, .. } => !payload.is_null(), CanonicalContentBlock::Image { .. } | CanonicalContentBlock::File { .. } | CanonicalContentBlock::Audio { .. } | CanonicalContentBlock::ToolUse { .. } | CanonicalContentBlock::ToolResult { .. } => true, }) } fn prepend_openai_responses_reasoning_block( message: &mut CanonicalMessage, reasoning: CanonicalContentBlock, ) { if message .content .iter() .any(|block| matches!(block, CanonicalContentBlock::Thinking { .. })) { return; } message.content.insert(0, reasoning); } fn openai_responses_reasoning_block_from_item( item_object: &Map, ) -> Option { let text = openai_responses_reasoning_text(item_object); let encrypted_content = item_object .get("encrypted_content") .and_then(Value::as_str) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); if text.is_empty() && encrypted_content.is_none() { return None; } let mut extensions = BTreeMap::new(); extensions.extend(openai_responses_extensions( item_object, &[ "type", "id", "status", "summary", "content", "encrypted_content", ], )); canonical_extension_object_mut(&mut extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE).insert( "item_type".to_string(), Value::String("reasoning".to_string()), ); canonical_extension_object_mut(&mut extensions, "openai") .insert("omit_reasoning_parts".to_string(), Value::Bool(true)); let extensions = openai_thinking_extensions(extensions); Some(CanonicalContentBlock::Thinking { text, signature: None, encrypted_content, extensions, }) } fn openai_responses_reasoning_text(item_object: &Map) -> String { let mut parts = openai_responses_reasoning_text_parts(item_object.get("content")); if parts.is_empty() { parts = openai_responses_reasoning_text_parts(item_object.get("summary")); } parts.join("\n") } fn openai_responses_reasoning_text_parts(raw: Option<&Value>) -> Vec { let Some(raw) = raw else { return Vec::new(); }; match raw { Value::Array(items) => items .iter() .filter_map(openai_responses_reasoning_text_part) .collect(), other => openai_responses_reasoning_text_part(other) .into_iter() .collect(), } } fn openai_responses_reasoning_text_part(raw: &Value) -> Option { if let Some(text) = raw.as_str() { return (!text.is_empty()).then(|| text.to_string()); } let raw_object = raw.as_object()?; let text = raw_object.get("text").and_then(Value::as_str)?; (!text.is_empty()).then(|| text.to_string()) } pub(crate) fn openai_responses_content_to_blocks( content: Option<&Value>, ) -> Option> { let Some(content) = content else { return Some(Vec::new()); }; match content { Value::Null => Some(Vec::new()), Value::String(text) => Some(vec![CanonicalContentBlock::Text { text: text.clone(), extensions: BTreeMap::new(), }]), Value::Array(parts) => parts .iter() .map(openai_responses_part_to_canonical_block) .collect(), _ => None, } } pub(crate) fn openai_responses_output_to_canonical( output: Option<&Value>, ) -> Option<(Vec, BTreeMap)> { let Some(output) = output else { return Some((Vec::new(), BTreeMap::new())); }; let output_items = output.as_array()?; let mut blocks = Vec::new(); let mut message_item_provenance = Vec::new(); for (index, item) in output_items.iter().enumerate() { let Some(item_object) = item.as_object() else { blocks.push(CanonicalContentBlock::Unknown { raw_type: String::new(), payload: item.clone(), extensions: openai_responses_raw_extensions(BTreeMap::new()), }); continue; }; let item_type = item_object .get("type") .and_then(Value::as_str) .unwrap_or_default() .trim() .to_ascii_lowercase(); match item_type.as_str() { "message" => { let message_extensions = openai_responses_extensions( item_object, &["type", "id", "status", "role", "content"], ) .remove(OPENAI_RESPONSES_EXTENSION_NAMESPACE) .and_then(|value| value.as_object().cloned()) .unwrap_or_default(); if !message_extensions.is_empty() { message_item_provenance.push(json!({ "output_index": index, "fields": message_extensions, })); } blocks.extend(openai_responses_content_to_blocks( item_object.get("content"), )?); } "reasoning" => { let mut emitted = false; let encrypted_content = item_object .get("encrypted_content") .and_then(Value::as_str) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); let mut texts = openai_responses_reasoning_text_parts(item_object.get("content")); if texts.is_empty() { texts = openai_responses_reasoning_text_parts(item_object.get("summary")); } for text in texts { if text.trim().is_empty() { continue; } let mut extensions = openai_responses_extensions( item_object, &[ "type", "id", "status", "summary", "content", "encrypted_content", ], ); canonical_extension_object_mut(&mut extensions, "openai") .insert("omit_reasoning_parts".to_string(), Value::Bool(true)); let extensions = openai_thinking_extensions(extensions); blocks.push(CanonicalContentBlock::Thinking { text, signature: None, encrypted_content: encrypted_content.clone(), extensions, }); emitted = true; } if !emitted && encrypted_content.is_some() { let mut extensions = openai_responses_extensions( item_object, &[ "type", "id", "status", "summary", "content", "encrypted_content", ], ); canonical_extension_object_mut(&mut extensions, "openai") .insert("omit_reasoning_parts".to_string(), Value::Bool(true)); let extensions = openai_thinking_extensions(extensions); blocks.push(CanonicalContentBlock::Thinking { text: String::new(), signature: None, encrypted_content, extensions, }); emitted = true; } if !emitted { blocks.push(CanonicalContentBlock::Unknown { raw_type: item_type, payload: item.clone(), extensions: openai_responses_raw_extensions(BTreeMap::new()), }); } } "function_call" => { let name = item_object .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; let id = item_object .get("call_id") .or_else(|| item_object.get("id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| format!("call_auto_{index}")); let mut extensions = openai_responses_extensions( item_object, &["type", "id", "call_id", "name", "arguments"], ); remember_openai_responses_tool_call_item_id(&mut extensions, item_object); blocks.push(CanonicalContentBlock::ToolUse { id, name: name.to_string(), input: parse_jsonish_value(item_object.get("arguments")), extensions, }); } "custom_tool_call" => { let name = item_object .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("custom_tool"); let id = item_object .get("call_id") .or_else(|| item_object.get("id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| format!("call_auto_{index}")); let mut extensions = openai_responses_extensions( item_object, &[ "type", "id", "call_id", "name", "input", "arguments", "status", ], ); remember_openai_responses_tool_call_item_id(&mut extensions, item_object); mark_openai_custom_tool_call(&mut extensions); blocks.push(CanonicalContentBlock::ToolUse { id, name: name.to_string(), input: parse_jsonish_value( item_object .get("input") .or_else(|| item_object.get("arguments")), ), extensions, }); } "web_search_call" => { let id = item_object .get("id") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| format!("call_auto_{index}")); let query = item_object .get("action") .and_then(Value::as_object) .and_then(|action| action.get("query")) .and_then(Value::as_str) .unwrap_or_default(); blocks.push(CanonicalContentBlock::ToolUse { id, name: "web_search".to_string(), input: json!({ "query": query }), extensions: openai_responses_extensions( item_object, &["type", "id", "status", "action"], ), }); } "local_shell_call" | "shell_call" | "apply_patch_call" | "computer_call" => { blocks.push(openai_responses_hosted_tool_call_to_block( item_object, item_type.as_str(), index, )?); } "function_call_output" => { let id = item_object .get("call_id") .or_else(|| item_object.get("tool_call_id")) .or_else(|| item_object.get("id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| format!("call_auto_{index}")); let raw_output = item_object.get("output"); let output = Some(raw_output.cloned().unwrap_or_else(|| json!({}))); let mut extensions = openai_responses_extensions( item_object, &[ "type", "id", "call_id", "tool_call_id", "output", "is_error", ], ); extensions.insert( AETHER_EXTENSION_NAMESPACE.to_string(), json!({ "source": OPENAI_RESPONSES_TOOL_RESULT_SOURCE_MARKER }), ); blocks.push(CanonicalContentBlock::ToolResult { tool_use_id: id, name: None, output, content_text: raw_output.map(openai_responses_tool_output_text), is_error: item_object .get("is_error") .and_then(Value::as_bool) .unwrap_or(false), extensions, }); } "custom_tool_call_output" | "local_shell_call_output" | "shell_call_output" | "apply_patch_call_output" | "computer_call_output" => { blocks.push(openai_responses_hosted_tool_result_to_block( item_object, item_type.as_str(), index, )); } "image_generation_call" => { blocks.push(openai_responses_image_generation_call_to_block( item_object, )?); } "output_text" | "text" | "output_image" | "image_url" | "file" | "input_file" | "input_audio" | "output_audio" => { blocks.push(openai_responses_part_to_canonical_block(item)?) } _ => blocks.push(CanonicalContentBlock::Unknown { raw_type: item_type, payload: item.clone(), extensions: openai_responses_raw_extensions(BTreeMap::new()), }), } } let mut extensions = BTreeMap::new(); if !message_item_provenance.is_empty() { extensions.insert( OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(), json!({ "message_items": message_item_provenance }), ); } Some((blocks, extensions)) } fn openai_responses_hosted_tool_call_to_block( item_object: &Map, item_type: &str, index: usize, ) -> Option { let name = openai_responses_hosted_tool_name(item_type)?; let id = item_object .get("call_id") .or_else(|| item_object.get("id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| format!("call_auto_{index}")); let input = openai_responses_hosted_tool_input(item_object, item_type); let mut extensions = openai_responses_extensions( item_object, &[ "type", "id", "call_id", "status", "action", "actions", "environment", "created_by", "max_output_length", "operation", "pending_safety_checks", ], ); canonical_extension_object_mut(&mut extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE).insert( "item_type".to_string(), Value::String(item_type.to_string()), ); Some(CanonicalContentBlock::ToolUse { id, name: name.to_string(), input, extensions, }) } fn openai_responses_hosted_tool_result_to_block( item_object: &Map, item_type: &str, index: usize, ) -> CanonicalContentBlock { let id = item_object .get("call_id") .or_else(|| item_object.get("tool_call_id")) .or_else(|| item_object.get("id")) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| format!("call_auto_{index}")); let raw_output = item_object .get("output") .or_else(|| item_object.get("content")); let mut extensions = openai_responses_extensions( item_object, &[ "type", "id", "call_id", "tool_call_id", "output", "content", "is_error", ], ); extensions.insert( AETHER_EXTENSION_NAMESPACE.to_string(), json!({ "source": OPENAI_RESPONSES_TOOL_RESULT_SOURCE_MARKER }), ); canonical_extension_object_mut(&mut extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE).insert( "item_type".to_string(), Value::String(item_type.to_string()), ); CanonicalContentBlock::ToolResult { tool_use_id: id, name: openai_responses_hosted_tool_result_name(item_type).map(ToOwned::to_owned), output: raw_output.map(|_| parse_jsonish_value(raw_output)), content_text: raw_output.map(openai_responses_tool_output_text), is_error: item_object .get("is_error") .and_then(Value::as_bool) .unwrap_or(false), extensions, } } fn openai_responses_hosted_tool_name(item_type: &str) -> Option<&'static str> { match item_type { "local_shell_call" => Some("local_shell"), "shell_call" => Some("shell"), "apply_patch_call" => Some("apply_patch"), "computer_call" => Some("computer"), _ => None, } } fn openai_responses_hosted_tool_result_name(item_type: &str) -> Option<&'static str> { match item_type { "local_shell_call_output" => Some("local_shell"), "shell_call_output" => Some("shell"), "apply_patch_call_output" => Some("apply_patch"), "computer_call_output" => Some("computer"), _ => None, } } fn openai_responses_hosted_tool_input(item_object: &Map, item_type: &str) -> Value { let fields = match item_type { "local_shell_call" | "shell_call" => &[ "action", "environment", "status", "created_by", "max_output_length", ][..], "apply_patch_call" => &["operation", "status"][..], "computer_call" => &["action", "actions", "pending_safety_checks", "status"][..], _ => &[][..], }; let mut input = Map::new(); for field in fields { if let Some(value) = item_object.get(*field) { input.insert((*field).to_string(), value.clone()); } } Value::Object(input) } fn openai_responses_image_generation_call_to_block( item_object: &Map, ) -> Option { let result = item_object .get("result") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let url = item_object .get("url") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()); let raw_image = result.or(url)?; let fallback_media_type = item_object .get("mime_type") .and_then(Value::as_str) .map(ToOwned::to_owned) .or_else(|| { item_object .get("output_format") .and_then(Value::as_str) .map(openai_responses_output_format_to_mime_type) }); let (media_type, data, url) = if raw_image.starts_with("data:image/") { split_data_url(Some(raw_image.to_string()), fallback_media_type) } else if raw_image.starts_with("http://") || raw_image.starts_with("https://") { (fallback_media_type, None, Some(raw_image.to_string())) } else if result.is_some() { ( fallback_media_type.or_else(|| Some("image/png".to_string())), Some(raw_image.to_string()), None, ) } else { (fallback_media_type, None, Some(raw_image.to_string())) }; let mut extensions = openai_responses_extensions( item_object, &[ "type", "id", "status", "action", "result", "url", "output_format", "mime_type", ], ); canonical_extension_object_mut(&mut extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE).insert( "item_type".to_string(), Value::String("image_generation_call".to_string()), ); Some(CanonicalContentBlock::Image { data, url, media_type, detail: None, extensions, }) } fn openai_responses_output_format_to_mime_type(output_format: &str) -> String { match output_format.trim().to_ascii_lowercase().as_str() { "jpeg" | "jpg" => "image/jpeg", "webp" => "image/webp", "gif" => "image/gif", _ => "image/png", } .to_string() } pub(crate) fn openai_responses_part_to_canonical_block( part: &Value, ) -> Option { let part_object = part.as_object()?; let raw_type = part_object .get("type") .and_then(Value::as_str) .unwrap_or_default() .trim() .to_ascii_lowercase(); match raw_type.as_str() { "input_text" | "output_text" | "text" => Some(CanonicalContentBlock::Text { text: part_object .get("text") .and_then(Value::as_str) .unwrap_or_default() .to_string(), extensions: { let mut extensions = openai_responses_extensions(part_object, &["type", "text"]); mark_openai_responses_content_block(&mut extensions); extensions }, }), "reasoning" | "thinking" => Some(CanonicalContentBlock::Thinking { text: part_object .get("text") .or_else(|| part_object.get("summary")) .and_then(Value::as_str) .unwrap_or_default() .to_string(), signature: part_object .get("signature") .and_then(Value::as_str) .map(ToOwned::to_owned), encrypted_content: part_object .get("encrypted_content") .and_then(Value::as_str) .map(ToOwned::to_owned), extensions: { let mut extensions = openai_responses_extensions( part_object, &["type", "text", "summary", "signature", "encrypted_content"], ); canonical_extension_object_mut(&mut extensions, "openai") .insert("omit_reasoning_parts".to_string(), Value::Bool(true)); openai_thinking_extensions(extensions) }, }), "input_image" | "output_image" | "image_url" => { let image_object = part_object.get("image_url").and_then(Value::as_object); let url = part_object .get("image_url") .and_then(Value::as_str) .or_else(|| { image_object .and_then(|image| image.get("url")) .and_then(Value::as_str) }) .or_else(|| part_object.get("url").and_then(Value::as_str)) .map(ToOwned::to_owned); let detail = part_object .get("detail") .and_then(Value::as_str) .or_else(|| { image_object .and_then(|image| image.get("detail")) .and_then(Value::as_str) }) .map(ToOwned::to_owned); let (media_type, data, url) = split_data_url(url, None); Some(CanonicalContentBlock::Image { data, url, media_type, detail, extensions: openai_responses_extensions( part_object, &["type", "image_url", "url", "detail"], ), }) } "input_file" | "file" => { let file_object = part_object .get("file") .and_then(Value::as_object) .unwrap_or(part_object); let file_data = file_object .get("file_data") .and_then(Value::as_str) .map(ToOwned::to_owned); let (media_type, data, file_url) = split_data_url( file_data.or_else(|| { file_object .get("file_url") .and_then(Value::as_str) .map(ToOwned::to_owned) }), file_object .get("mime_type") .and_then(Value::as_str) .map(ToOwned::to_owned), ); Some(CanonicalContentBlock::File { data, file_id: file_object .get("file_id") .and_then(Value::as_str) .map(ToOwned::to_owned), file_url, media_type, filename: file_object .get("filename") .and_then(Value::as_str) .map(ToOwned::to_owned), extensions: openai_responses_extensions( part_object, &[ "type", "file_data", "file_url", "mime_type", "file_id", "filename", ], ), }) } "input_audio" | "audio" => { let audio_object = part_object .get("input_audio") .and_then(Value::as_object) .unwrap_or(part_object); let format = audio_object .get("format") .and_then(Value::as_str) .map(ToOwned::to_owned); Some(CanonicalContentBlock::Audio { data: audio_object .get("data") .and_then(Value::as_str) .map(ToOwned::to_owned), media_type: audio_object .get("media_type") .and_then(Value::as_str) .map(ToOwned::to_owned) .or_else(|| format.as_ref().map(|value| format!("audio/{value}"))), format, extensions: openai_responses_extensions(part_object, &["type", "input_audio"]), }) } "output_audio" => { let mut extensions = openai_responses_extensions(part_object, &["type", "data"]); canonical_extension_object_mut(&mut extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE) .insert( "item_type".to_string(), Value::String("output_audio".to_string()), ); mark_openai_output_audio(&mut extensions); Some(CanonicalContentBlock::Audio { data: part_object .get("data") .and_then(Value::as_str) .map(ToOwned::to_owned), media_type: None, format: None, extensions, }) } "refusal" => Some(CanonicalContentBlock::Unknown { raw_type, payload: part.clone(), extensions: openai_responses_raw_content_extensions(BTreeMap::new()), }), _ => Some(CanonicalContentBlock::Unknown { raw_type, payload: part.clone(), extensions: openai_responses_raw_content_extensions(BTreeMap::new()), }), } } pub(crate) fn openai_content_to_blocks( content: Option<&Value>, ) -> Option> { let Some(content) = content else { return Some(Vec::new()); }; match content { Value::Null => Some(Vec::new()), Value::String(text) => Some(vec![CanonicalContentBlock::Text { text: text.clone(), extensions: BTreeMap::new(), }]), Value::Array(parts) => parts.iter().map(openai_part_to_canonical_block).collect(), _ => None, } } pub(crate) fn openai_part_to_canonical_block(part: &Value) -> Option { let part_object = part.as_object()?; let raw_type = part_object .get("type") .and_then(Value::as_str) .unwrap_or_default() .trim() .to_ascii_lowercase(); match raw_type.as_str() { "text" | "input_text" | "output_text" => Some(CanonicalContentBlock::Text { text: part_object .get("text") .and_then(Value::as_str) .unwrap_or_default() .to_string(), extensions: openai_extensions(part_object, &["type", "text"]), }), "image_url" | "input_image" | "output_image" => { let image_object = part_object.get("image_url").and_then(Value::as_object); let url = part_object .get("image_url") .and_then(Value::as_str) .or_else(|| { image_object .and_then(|image| image.get("url")) .and_then(Value::as_str) }) .or_else(|| part_object.get("url").and_then(Value::as_str)) .map(ToOwned::to_owned); let detail = part_object .get("detail") .and_then(Value::as_str) .or_else(|| { image_object .and_then(|image| image.get("detail")) .and_then(Value::as_str) }) .map(ToOwned::to_owned); let (media_type, data, url) = split_data_url(url, None); Some(CanonicalContentBlock::Image { data, url, media_type, detail, extensions: openai_extensions(part_object, &["type", "image_url", "url", "detail"]), }) } "file" | "input_file" => { let file_object = part_object .get("file") .and_then(Value::as_object) .unwrap_or(part_object); let file_data = file_object .get("file_data") .and_then(Value::as_str) .map(str::to_string); let (media_type, data, file_url) = split_data_url( file_data.or_else(|| { file_object .get("file_url") .and_then(Value::as_str) .map(str::to_string) }), file_object .get("mime_type") .and_then(Value::as_str) .map(str::to_string), ); Some(CanonicalContentBlock::File { data, file_id: file_object .get("file_id") .and_then(Value::as_str) .map(ToOwned::to_owned), file_url, media_type, filename: file_object .get("filename") .and_then(Value::as_str) .map(ToOwned::to_owned), extensions: openai_extensions(part_object, &["type", "file"]), }) } "input_audio" => { let audio_object = part_object.get("input_audio").and_then(Value::as_object)?; let data = audio_object .get("data") .and_then(Value::as_str) .map(str::to_string); let format = audio_object .get("format") .and_then(Value::as_str) .map(str::to_string); Some(CanonicalContentBlock::Audio { data, media_type: format.as_ref().map(|value| format!("audio/{value}")), format, extensions: openai_extensions(part_object, &["type", "input_audio"]), }) } _ => Some(CanonicalContentBlock::Unknown { raw_type, payload: part.clone(), extensions: BTreeMap::new(), }), } } pub(crate) fn canonical_message_to_openai_chat_messages(message: &CanonicalMessage) -> Vec { let mut messages = Vec::new(); let mut pending_start = 0usize; let mut saw_tool_result = false; for (index, block) in message.content.iter().enumerate() { if let CanonicalContentBlock::ToolResult { .. } = block { saw_tool_result = true; if pending_start < index { if let Some(message_value) = canonical_message_blocks_to_openai_chat( message, &message.content[pending_start..index], false, ) { messages.push(message_value); } } messages.push(canonical_tool_result_to_openai_chat(block)); pending_start = index + 1; } } if !saw_tool_result { return vec![canonical_message_without_tool_results_to_openai_chat( message, )]; } if pending_start < message.content.len() { if let Some(message_value) = canonical_message_blocks_to_openai_chat( message, &message.content[pending_start..], false, ) { messages.push(message_value); } } messages } fn canonical_message_without_tool_results_to_openai_chat(message: &CanonicalMessage) -> Value { debug_assert!( !message .content .iter() .any(|block| matches!(block, CanonicalContentBlock::ToolResult { .. })), "single OpenAI Chat message emission requires no ToolResult blocks; use canonical_message_to_openai_chat_messages" ); canonical_message_blocks_to_openai_chat(message, &message.content, true) .expect("include_empty=true always emits a chat message") } fn canonical_message_blocks_to_openai_chat( message: &CanonicalMessage, content: &[CanonicalContentBlock], include_empty: bool, ) -> Option { let mut output = Map::new(); output.insert( "role".to_string(), Value::String( match message.role { CanonicalRole::Assistant => "assistant", CanonicalRole::System => "system", CanonicalRole::Developer => "system", CanonicalRole::Tool => "tool", CanonicalRole::Unknown | CanonicalRole::User => "user", } .to_string(), ), ); let mut content_parts = Vec::new(); let mut tool_calls = Vec::new(); let mut reasoning_segments = Vec::new(); let mut reasoning_parts = Vec::new(); for block in content { match block { CanonicalContentBlock::Thinking { text, signature, encrypted_content, extensions, } if matches!(message.role, CanonicalRole::Assistant) => { if let Some(data) = encrypted_content.as_ref().filter(|value| !value.is_empty()) { reasoning_parts.push(json!({ "type": "redacted_thinking", "data": data, })); continue; } if !text.trim().is_empty() { let omit_reasoning_content = extensions .get("openai") .and_then(|value| value.get("omit_reasoning_content")) .and_then(Value::as_bool) .unwrap_or(false); let omit_reasoning_parts = extensions .get("openai") .and_then(|value| value.get("omit_reasoning_parts")) .and_then(Value::as_bool) .unwrap_or(false); if !omit_reasoning_content { reasoning_segments.push(text.clone()); } if !omit_reasoning_parts { let mut reasoning_part = Map::new(); reasoning_part .insert("type".to_string(), Value::String("thinking".to_string())); reasoning_part.insert("thinking".to_string(), Value::String(text.clone())); if let Some(signature) = signature.as_ref().filter(|value| !value.is_empty()) { reasoning_part .insert("signature".to_string(), Value::String(signature.clone())); } reasoning_parts.push(Value::Object(reasoning_part)); } } } CanonicalContentBlock::ToolUse { id, name, input, extensions, } => tool_calls.push(canonical_tool_use_to_openai_chat_tool_call( id, name, input, extensions, )), CanonicalContentBlock::ToolResult { .. } => {} other => { if let Some(part) = canonical_content_block_to_openai_part(other) { content_parts.push(part); } } } } if !include_empty && content_parts.is_empty() && tool_calls.is_empty() && reasoning_segments.is_empty() && reasoning_parts.is_empty() { return None; } output.insert( "content".to_string(), if !tool_calls.is_empty() && content_parts.is_empty() { Value::Null } else { openai_content_value_from_parts(content_parts, false) }, ); if !tool_calls.is_empty() { output.insert("tool_calls".to_string(), Value::Array(tool_calls)); } if !reasoning_segments.is_empty() { output.insert( "reasoning_content".to_string(), Value::String(reasoning_segments.join("")), ); } if !reasoning_parts.is_empty() { output.insert("reasoning_parts".to_string(), Value::Array(reasoning_parts)); } Some(Value::Object(output)) } fn canonical_tool_result_to_openai_chat(block: &CanonicalContentBlock) -> Value { let CanonicalContentBlock::ToolResult { tool_use_id, content_text, output: result_output, is_error, extensions, .. } = block else { unreachable!("canonical_tool_result_to_openai_chat requires ToolResult"); }; let mut output = Map::new(); output.insert("role".to_string(), Value::String("tool".to_string())); output.insert( "tool_call_id".to_string(), Value::String(tool_use_id.clone()), ); let content = if is_claude_tool_result(extensions) { let content = openai_chat_tool_result_content(result_output.as_ref(), content_text.as_deref()); if *is_error { openai_chat_tool_error_content(content) } else { content } } else if is_openai_responses_tool_result(extensions) { openai_responses_tool_result_content_for_chat( result_output.as_ref(), content_text.as_deref(), ) } else { result_output .clone() .unwrap_or_else(|| Value::String(content_text.clone().unwrap_or_default())) }; output.insert("content".to_string(), content); Value::Object(output) } fn claude_thinking_extensions(mut extensions: BTreeMap) -> BTreeMap { canonical_extension_object_mut(&mut extensions, AETHER_EXTENSION_NAMESPACE).insert( "source".to_string(), Value::String(CLAUDE_THINKING_SOURCE_MARKER.to_string()), ); extensions } fn claude_raw_extensions(mut extensions: BTreeMap) -> BTreeMap { canonical_extension_object_mut(&mut extensions, AETHER_EXTENSION_NAMESPACE).insert( "source".to_string(), Value::String(CLAUDE_RAW_SOURCE_MARKER.to_string()), ); extensions } fn openai_responses_raw_extensions( mut extensions: BTreeMap, ) -> BTreeMap { canonical_extension_object_mut(&mut extensions, AETHER_EXTENSION_NAMESPACE).insert( "source".to_string(), Value::String(OPENAI_RESPONSES_RAW_SOURCE_MARKER.to_string()), ); extensions } fn openai_responses_raw_content_extensions( mut extensions: BTreeMap, ) -> BTreeMap { canonical_extension_object_mut(&mut extensions, AETHER_EXTENSION_NAMESPACE).insert( "source".to_string(), Value::String(OPENAI_RESPONSES_RAW_CONTENT_SOURCE_MARKER.to_string()), ); extensions } fn mark_openai_responses_content_block(extensions: &mut BTreeMap) { canonical_extension_object_mut(extensions, AETHER_EXTENSION_NAMESPACE).insert( OPENAI_RESPONSES_CONTENT_MARKER.to_string(), Value::Bool(true), ); } fn openai_thinking_extensions(mut extensions: BTreeMap) -> BTreeMap { canonical_extension_object_mut(&mut extensions, AETHER_EXTENSION_NAMESPACE).insert( "source".to_string(), Value::String(OPENAI_THINKING_SOURCE_MARKER.to_string()), ); extensions } fn mark_openai_custom_tool_call(extensions: &mut BTreeMap) { canonical_extension_object_mut(extensions, AETHER_EXTENSION_NAMESPACE).insert( "source".to_string(), Value::String(OPENAI_CUSTOM_TOOL_CALL_SOURCE_MARKER.to_string()), ); } fn remember_openai_responses_tool_call_item_id( extensions: &mut BTreeMap, item_object: &Map, ) { if let Some(item_id) = item_object .get("id") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { canonical_extension_object_mut(extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE) .insert("item_id".to_string(), Value::String(item_id.to_string())); } } fn mark_openai_output_audio(extensions: &mut BTreeMap) { canonical_extension_object_mut(extensions, AETHER_EXTENSION_NAMESPACE).insert( "source".to_string(), Value::String(OPENAI_OUTPUT_AUDIO_SOURCE_MARKER.to_string()), ); } fn mark_openai_responses_input_message(extensions: &mut BTreeMap) { canonical_extension_object_mut(extensions, AETHER_EXTENSION_NAMESPACE).insert( "source".to_string(), Value::String(OPENAI_RESPONSES_INPUT_MESSAGE_SOURCE_MARKER.to_string()), ); } pub(crate) fn is_claude_thinking_block(extensions: &BTreeMap) -> bool { extensions .get(AETHER_EXTENSION_NAMESPACE) .and_then(|value| value.get("source")) .and_then(Value::as_str) == Some(CLAUDE_THINKING_SOURCE_MARKER) } fn is_claude_raw_block(extensions: &BTreeMap) -> bool { extensions .get(AETHER_EXTENSION_NAMESPACE) .and_then(|value| value.get("source")) .and_then(Value::as_str) == Some(CLAUDE_RAW_SOURCE_MARKER) } pub(crate) fn is_openai_responses_raw_block(extensions: &BTreeMap) -> bool { extensions .get(AETHER_EXTENSION_NAMESPACE) .and_then(|value| value.get("source")) .and_then(Value::as_str) == Some(OPENAI_RESPONSES_RAW_SOURCE_MARKER) } pub(crate) fn is_openai_responses_raw_content_block(extensions: &BTreeMap) -> bool { extensions .get(AETHER_EXTENSION_NAMESPACE) .and_then(|value| value.get("source")) .and_then(Value::as_str) == Some(OPENAI_RESPONSES_RAW_CONTENT_SOURCE_MARKER) } pub(crate) fn is_openai_responses_content_block(extensions: &BTreeMap) -> bool { extensions .get(AETHER_EXTENSION_NAMESPACE) .and_then(|value| value.get(OPENAI_RESPONSES_CONTENT_MARKER)) .and_then(Value::as_bool) == Some(true) } pub(crate) fn is_openai_responses_input_message(extensions: &BTreeMap) -> bool { extensions .get(AETHER_EXTENSION_NAMESPACE) .and_then(|value| value.get("source")) .and_then(Value::as_str) == Some(OPENAI_RESPONSES_INPUT_MESSAGE_SOURCE_MARKER) } pub(crate) fn is_openai_thinking_block(extensions: &BTreeMap) -> bool { extensions .get(AETHER_EXTENSION_NAMESPACE) .and_then(|value| value.get("source")) .and_then(Value::as_str) == Some(OPENAI_THINKING_SOURCE_MARKER) } pub(crate) fn is_openai_custom_tool_call(extensions: &BTreeMap) -> bool { extensions .get(AETHER_EXTENSION_NAMESPACE) .and_then(|value| value.get("source")) .and_then(Value::as_str) == Some(OPENAI_CUSTOM_TOOL_CALL_SOURCE_MARKER) } fn is_openai_output_audio_block(extensions: &BTreeMap) -> bool { extensions .get(AETHER_EXTENSION_NAMESPACE) .and_then(|value| value.get("source")) .and_then(Value::as_str) == Some(OPENAI_OUTPUT_AUDIO_SOURCE_MARKER) } pub(crate) fn is_claude_tool_result(extensions: &BTreeMap) -> bool { extensions .get(AETHER_EXTENSION_NAMESPACE) .and_then(|value| value.get("source")) .and_then(Value::as_str) == Some(CLAUDE_TOOL_RESULT_SOURCE_MARKER) } pub(crate) fn is_cross_format_tool_result(extensions: &BTreeMap) -> bool { is_claude_tool_result(extensions) || is_openai_chat_tool_result(extensions) || is_openai_responses_tool_result(extensions) } fn is_openai_responses_tool_result(extensions: &BTreeMap) -> bool { extensions .get(AETHER_EXTENSION_NAMESPACE) .and_then(|value| value.get("source")) .and_then(Value::as_str) == Some(OPENAI_RESPONSES_TOOL_RESULT_SOURCE_MARKER) } fn openai_responses_tool_result_content_for_chat( output: Option<&Value>, content_text: Option<&str>, ) -> Value { if let Some(text) = content_text { return Value::String(text.to_string()); } match output { Some(Value::String(text)) => Value::String(text.clone()), Some(value) => Value::String(value.to_string()), None => Value::String(String::new()), } } fn openai_chat_tool_result_content(output: Option<&Value>, content_text: Option<&str>) -> Value { match output { Some(Value::String(text)) => Value::String(text.clone()), Some(Value::Array(parts)) => anthropic_tool_result_blocks_to_openai_chat_content(parts), Some(value) => Value::String(value.to_string()), None => Value::String(content_text.unwrap_or_default().to_string()), } } fn openai_chat_tool_error_content(content: Value) -> Value { match content { Value::String(text) if text.is_empty() => { Value::String(OPENAI_CHAT_TOOL_ERROR_PREFIX.to_string()) } Value::String(text) => Value::String(format!("{OPENAI_CHAT_TOOL_ERROR_PREFIX}\n{text}")), Value::Array(parts) => { let mut prefixed_parts = Vec::with_capacity(parts.len() + 1); prefixed_parts.push(openai_text_part(OPENAI_CHAT_TOOL_ERROR_PREFIX)); prefixed_parts.extend(parts); Value::Array(prefixed_parts) } value => Value::String(format!("{OPENAI_CHAT_TOOL_ERROR_PREFIX}\n{value}")), } } fn anthropic_tool_result_blocks_to_openai_chat_content(parts: &[Value]) -> Value { if let Some(text) = anthropic_text_blocks_to_string(parts) { return Value::String(text); } let mut has_media_part = false; let mut converted_parts = Vec::with_capacity(parts.len()); for part in parts { let Some(openai_part) = anthropic_tool_result_block_to_openai_chat_part(part) else { return Value::String(Value::Array(parts.to_vec()).to_string()); }; if !openai_chat_part_is_text(&openai_part) { has_media_part = true; } converted_parts.push(openai_part); } if has_media_part { Value::Array(converted_parts) } else { Value::String(openai_text_parts_to_string(&converted_parts)) } } fn anthropic_text_blocks_to_string(parts: &[Value]) -> Option { let mut texts = Vec::with_capacity(parts.len()); for part in parts { let part_object = part.as_object()?; if part_object.get("type").and_then(Value::as_str) != Some("text") { return None; } texts.push(part_object.get("text").and_then(Value::as_str)?); } Some(texts.join("\n\n")) } fn anthropic_tool_result_block_to_openai_chat_part(part: &Value) -> Option { let part_object = part.as_object()?; match part_object .get("type") .and_then(Value::as_str) .unwrap_or_default() { "text" => Some(openai_text_part( part_object .get("text") .and_then(Value::as_str) .unwrap_or_default(), )), "image" => anthropic_image_block_to_openai_chat_part(part_object), "document" | "file" => anthropic_document_block_to_openai_chat_part(part_object), _ => None, } } fn anthropic_image_block_to_openai_chat_part(block: &Map) -> Option { let source = block.get("source")?.as_object()?; match source .get("type") .and_then(Value::as_str) .unwrap_or_default() { "base64" => { let media_type = anthropic_source_media_type(source)?; let data = anthropic_source_str(source, "data")?; Some(json!({ "type": "image_url", "image_url": { "url": format!("data:{media_type};base64,{data}"), }, })) } "url" => { let url = anthropic_source_str(source, "url")?; Some(json!({ "type": "image_url", "image_url": { "url": url, }, })) } _ => None, } } fn anthropic_document_block_to_openai_chat_part(block: &Map) -> Option { let source = block.get("source")?.as_object()?; match source .get("type") .and_then(Value::as_str) .unwrap_or_default() { "base64" => { let media_type = anthropic_source_media_type(source)?; let data = anthropic_source_str(source, "data")?; Some(json!({ "type": "file", "file": { "file_data": format!("data:{media_type};base64,{data}"), }, })) } "url" => { let url = anthropic_source_str(source, "url")?; Some(openai_text_part(format!("[File: {url}]"))) } "text" => anthropic_source_str(source, "data").map(openai_text_part), _ => None, } } fn openai_text_part(text: impl Into) -> Value { json!({ "type": "text", "text": text.into(), }) } fn openai_chat_part_is_text(part: &Value) -> bool { part.as_object() .and_then(|object| object.get("type")) .and_then(Value::as_str) == Some("text") } fn openai_text_parts_to_string(parts: &[Value]) -> String { parts .iter() .filter_map(|part| { part.as_object() .and_then(|object| object.get("text")) .and_then(Value::as_str) }) .collect::>() .join("\n\n") } fn anthropic_source_media_type(source: &Map) -> Option<&str> { source .get("media_type") .or_else(|| source.get("mime_type")) .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) } fn anthropic_source_str<'a>(source: &'a Map, key: &str) -> Option<&'a str> { source .get(key) .and_then(Value::as_str) .filter(|value| !value.trim().is_empty()) } pub(crate) fn canonical_content_block_to_openai_part( block: &CanonicalContentBlock, ) -> Option { match block { CanonicalContentBlock::Text { text, extensions } => { let mut part = Map::new(); part.insert("type".to_string(), Value::String("text".to_string())); part.insert("text".to_string(), Value::String(text.clone())); insert_openai_prompt_cache_breakpoint(&mut part, extensions); Some(Value::Object(part)) } CanonicalContentBlock::Image { data, url, media_type, detail, extensions, } => { let mut image = Map::new(); image.insert( "url".to_string(), Value::String(media_data_or_url(media_type, data, url)), ); if let Some(detail) = detail { image.insert("detail".to_string(), Value::String(detail.clone())); } let mut part = Map::new(); part.insert("type".to_string(), Value::String("image_url".to_string())); part.insert("image_url".to_string(), Value::Object(image)); insert_openai_prompt_cache_breakpoint(&mut part, extensions); Some(Value::Object(part)) } CanonicalContentBlock::File { data, file_id, file_url, media_type, filename, extensions, } => { let mut file = Map::new(); if let Some(value) = file_id { file.insert("file_id".to_string(), Value::String(value.clone())); } if data.is_some() || file_url.is_some() { if data.is_some() { file.insert( "file_data".to_string(), Value::String(media_data_or_url(media_type, data, file_url)), ); } else if let Some(value) = file_url { file.insert("file_url".to_string(), Value::String(value.clone())); } } if let Some(value) = filename { file.insert("filename".to_string(), Value::String(value.clone())); } let mut part = Map::new(); part.insert("type".to_string(), Value::String("file".to_string())); part.insert("file".to_string(), Value::Object(file)); insert_openai_prompt_cache_breakpoint(&mut part, extensions); Some(Value::Object(part)) } CanonicalContentBlock::Audio { data, format, extensions, .. } => { let mut part = Map::new(); part.insert("type".to_string(), Value::String("input_audio".to_string())); part.insert( "input_audio".to_string(), json!({ "data": data.clone().unwrap_or_default(), "format": format.clone().unwrap_or_else(|| "mp3".to_string()), }), ); insert_openai_prompt_cache_breakpoint(&mut part, extensions); Some(Value::Object(part)) } CanonicalContentBlock::Thinking { text, .. } => Some(json!({ "type": "text", "text": text, })), CanonicalContentBlock::ToolUse { .. } | CanonicalContentBlock::ToolResult { .. } | CanonicalContentBlock::Unknown { .. } => None, } } pub(crate) fn openai_prompt_cache_breakpoint_from_extensions( extensions: &BTreeMap, ) -> Option { [ "openai", OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE, ] .into_iter() .find_map(|namespace| { extensions .get(namespace) .and_then(|value| value.get("prompt_cache_breakpoint")) .cloned() }) } fn insert_openai_prompt_cache_breakpoint( part: &mut Map, extensions: &BTreeMap, ) { if let Some(value) = openai_prompt_cache_breakpoint_from_extensions(extensions) { part.insert("prompt_cache_breakpoint".to_string(), value); } } pub(crate) fn canonical_content_block_to_openai_responses_part( block: &CanonicalContentBlock, ) -> Option { match block { CanonicalContentBlock::Text { text, extensions } => { let mut part = json!({ "type": "output_text", "text": text, "annotations": [], }); if let Some(annotations) = openai_responses_extension(extensions) .and_then(|value| value.get("annotations")) .cloned() { part["annotations"] = annotations; } Some(part) } CanonicalContentBlock::Image { data, url, media_type, detail, .. } => { let mut part = Map::new(); part.insert( "type".to_string(), Value::String("output_image".to_string()), ); part.insert( "image_url".to_string(), Value::String(media_data_or_url(media_type, data, url)), ); if let Some(detail) = detail { part.insert("detail".to_string(), Value::String(detail.clone())); } Some(Value::Object(part)) } CanonicalContentBlock::File { data, file_id, file_url, media_type, filename, .. } => { let mut file = Map::new(); if let Some(value) = file_id { file.insert("file_id".to_string(), Value::String(value.clone())); } if data.is_some() || file_url.is_some() { if data.is_some() { file.insert( "file_data".to_string(), Value::String(media_data_or_url(media_type, data, file_url)), ); } else if let Some(value) = file_url { file.insert("file_url".to_string(), Value::String(value.clone())); } } if let Some(value) = filename { file.insert("filename".to_string(), Value::String(value.clone())); } Some(json!({ "type": "file", "file": Value::Object(file), })) } CanonicalContentBlock::Audio { data, format, extensions, .. } => { if is_openai_output_audio_block(extensions) { let mut part = Map::new(); part.insert( "type".to_string(), Value::String("output_audio".to_string()), ); if let Some(data) = data.as_ref().filter(|value| !value.is_empty()) { part.insert("data".to_string(), Value::String(data.clone())); } else if let Some(raw_data) = openai_responses_extension(extensions) .and_then(|value| value.get("data")) .cloned() { part.insert("data".to_string(), raw_data); } if let Some(transcript) = openai_responses_extension(extensions) .and_then(|value| value.get("transcript")) .cloned() .or_else(|| { extensions .get("openai") .and_then(|value| value.get("audio")) .and_then(|value| value.get("transcript")) .cloned() }) { part.insert("transcript".to_string(), transcript); } return Some(Value::Object(part)); } Some(json!({ "type": "input_audio", "input_audio": { "data": data.clone().unwrap_or_default(), "format": format.clone().unwrap_or_else(|| "mp3".to_string()), } })) } CanonicalContentBlock::Thinking { .. } | CanonicalContentBlock::ToolUse { .. } | CanonicalContentBlock::ToolResult { .. } | CanonicalContentBlock::Unknown { .. } => None, } } pub(crate) fn flush_openai_responses_message_item( output: &mut Vec, message_content: &mut Vec, response_id: &str, message_index: &mut usize, ) { if message_content.is_empty() { return; } let id = openai_responses_message_item_id(response_id, *message_index); output.push(json!({ "type": "message", "id": id, "role": "assistant", "status": "completed", "content": coalesce_openai_responses_text_content(std::mem::take(message_content)), })); *message_index += 1; } pub(crate) fn openai_content_value_from_parts(parts: Vec, tool_only: bool) -> Value { if parts.is_empty() && tool_only { return Value::Null; } if parts.is_empty() { return Value::String(String::new()); } if parts.len() == 1 { if let Some(text) = parts[0] .as_object() .and_then(|object| object.get("text")) .and_then(Value::as_str) { return Value::String(text.to_string()); } } if parts.iter().all(|part| { part.as_object() .and_then(|object| object.get("text")) .and_then(Value::as_str) .is_some() && part .as_object() .and_then(|object| object.get("type")) .and_then(Value::as_str) .is_none_or(|part_type| part_type == "text") }) { return Value::String( parts .iter() .filter_map(|part| { part.as_object() .and_then(|object| object.get("text")) .and_then(Value::as_str) }) .collect::>() .join(""), ); } Value::Array(parts) } fn coalesce_openai_responses_text_content(content: Vec) -> Vec { if content.len() <= 1 { return content; } let mut text = String::new(); let mut annotations = Vec::new(); for part in &content { let Some(part_object) = part.as_object() else { return content; }; let part_type = part_object .get("type") .and_then(Value::as_str) .unwrap_or_default(); if !matches!(part_type, "output_text" | "text") { return content; } let Some(part_text) = part_object.get("text").and_then(Value::as_str) else { return content; }; text.push_str(part_text); if let Some(part_annotations) = part_object.get("annotations").and_then(Value::as_array) { annotations.extend(part_annotations.iter().cloned()); } } vec![json!({ "type": "output_text", "text": text, "annotations": annotations, })] } pub(crate) fn openai_content_text(content: Option<&Value>) -> String { match content { Some(Value::String(text)) => text.clone(), Some(Value::Array(parts)) => parts .iter() .filter_map(|part| { part.as_object() .and_then(|object| object.get("text")) .and_then(Value::as_str) .map(ToOwned::to_owned) }) .collect::>() .join("\n"), _ => String::new(), } } pub(crate) fn claude_generation_config(request: &Map) -> CanonicalGenerationConfig { CanonicalGenerationConfig { max_tokens: request.get("max_tokens").and_then(Value::as_u64), temperature: request.get("temperature").and_then(Value::as_f64), top_p: request.get("top_p").and_then(Value::as_f64), top_k: request.get("top_k").and_then(Value::as_u64), stop_sequences: request .get("stop") .or_else(|| request.get("stop_sequences")) .and_then(openai_stop_to_vec), ..CanonicalGenerationConfig::default() } } pub(crate) fn claude_tools_to_canonical( value: Option<&Value>, ) -> Option<(Vec, Vec, Option)> { let Some(value) = value else { return Some((Vec::new(), Vec::new(), None)); }; let tools = value.as_array()?; let mut canonical = Vec::new(); let mut builtin_tools = Vec::new(); let mut web_search_options = None; for tool in tools { let tool_object = tool.as_object()?; let tool_type = tool_object .get("type") .and_then(Value::as_str) .unwrap_or_default() .trim() .to_ascii_lowercase(); if tool_type.starts_with("web_search") { web_search_options = claude_web_search_tool_to_openai_options(tool_object); builtin_tools.push(tool.clone()); continue; } let name = tool_object .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; canonical.push(CanonicalToolDefinition { name: name.to_string(), description: tool_object .get("description") .and_then(Value::as_str) .map(ToOwned::to_owned), parameters: tool_object.get("input_schema").cloned(), strict: None, extensions: { let mut extensions = claude_extensions( tool_object, &["type", "name", "description", "input_schema"], ); if let Some(input_schema) = tool_object.get("input_schema").cloned() { canonical_extension_object_mut(&mut extensions, "claude") .insert("raw_input_schema".to_string(), input_schema); } extensions }, }); } Some((canonical, builtin_tools, web_search_options)) } pub(crate) fn claude_web_search_tool_to_openai_options(tool: &Map) -> Option { let mut options = Map::new(); if let Some(max_uses) = tool.get("max_uses").and_then(Value::as_u64) { let search_context_size = if max_uses <= 1 { "low" } else if max_uses <= 5 { "medium" } else { "high" }; options.insert( "search_context_size".to_string(), Value::String(search_context_size.to_string()), ); } if let Some(user_location) = tool.get("user_location").and_then(Value::as_object) { let mut approximate = Map::new(); for field in ["city", "country", "region", "timezone"] { if let Some(value) = user_location.get(field).cloned() { approximate.insert(field.to_string(), value); } } if !approximate.is_empty() { options.insert( "user_location".to_string(), json!({ "type": "approximate", "approximate": approximate, }), ); } } Some(Value::Object(options)) } pub(crate) fn claude_tool_choice_to_canonical( value: Option<&Value>, ) -> Option { match value { Some(Value::String(value)) => match value.trim().to_ascii_lowercase().as_str() { "auto" => Some(CanonicalToolChoice::Auto), "any" => Some(CanonicalToolChoice::Required), "none" => Some(CanonicalToolChoice::None), _ => None, }, Some(Value::Object(object)) => { if let Some(name) = object.get("name").and_then(Value::as_str) { return Some(CanonicalToolChoice::Tool { name: name.to_string(), }); } match object .get("type") .and_then(Value::as_str) .unwrap_or_default() .trim() .to_ascii_lowercase() .as_str() { "auto" => Some(CanonicalToolChoice::Auto), "any" => Some(CanonicalToolChoice::Required), "none" => Some(CanonicalToolChoice::None), "tool" => object.get("name").and_then(Value::as_str).map(|name| { CanonicalToolChoice::Tool { name: name.to_string(), } }), _ => None, } } _ => None, } } pub(crate) fn claude_parallel_tool_calls(value: Option<&Value>) -> Option { let object = value?.as_object()?; let choice_type = object .get("type") .and_then(Value::as_str) .unwrap_or_default() .trim() .to_ascii_lowercase(); if choice_type == "none" { return None; } object .get("disable_parallel_tool_use") .and_then(Value::as_bool) .map(|value| !value) } pub(crate) fn claude_thinking_to_canonical( request: &Map, ) -> Option { let thinking = request.get("thinking").and_then(Value::as_object); let output_config = request.get("output_config").and_then(Value::as_object); if thinking.is_none() && output_config.is_none() { return None; } let mut extensions = BTreeMap::new(); if let Some(thinking) = thinking { extensions.insert("claude".to_string(), Value::Object(thinking.clone())); } if let Some(output_config) = output_config { extensions .entry("claude".to_string()) .or_insert_with(|| Value::Object(Map::new())); if let Some(object) = extensions.get_mut("claude").and_then(Value::as_object_mut) { object.insert( "output_config".to_string(), Value::Object(output_config.clone()), ); } } if let Some(reasoning_effort) = output_config .and_then(|value| value.get("effort")) .and_then(Value::as_str) .and_then(claude_output_effort_to_openai_reasoning_effort) .or_else(|| { thinking .and_then(|value| value.get("budget_tokens")) .and_then(Value::as_u64) .map(map_thinking_budget_to_openai_reasoning_effort) }) { extensions.insert( "openai".to_string(), json!({ "reasoning_effort": reasoning_effort }), ); } Some(CanonicalThinkingConfig { enabled: thinking .and_then(|value| value.get("type")) .and_then(Value::as_str) .is_none_or(|value| value == "enabled"), budget_tokens: thinking .and_then(|value| value.get("budget_tokens")) .and_then(Value::as_u64), extensions, }) } pub(crate) fn claude_output_effort_to_openai_reasoning_effort(value: &str) -> Option<&'static str> { ReasoningEffort::parse(value).map(ReasoningEffort::as_openai_chat_value) } pub(crate) fn canonical_openai_reasoning_effort( thinking: &CanonicalThinkingConfig, ) -> Option<&str> { thinking .extensions .get("openai") .and_then(|value| value.get("reasoning_effort")) .and_then(Value::as_str) .or_else(|| { openai_responses_extension(&thinking.extensions) .and_then(|value| value.get("effort")) .and_then(Value::as_str) }) } pub(crate) fn gemini_generation_config(value: Option<&Value>) -> CanonicalGenerationConfig { let Some(generation_config) = value.and_then(Value::as_object) else { return CanonicalGenerationConfig::default(); }; CanonicalGenerationConfig { max_tokens: gemini_value_by_case(generation_config, "maxOutputTokens", "max_output_tokens") .and_then(Value::as_u64), temperature: generation_config.get("temperature").and_then(Value::as_f64), top_p: gemini_value_by_case(generation_config, "topP", "top_p").and_then(Value::as_f64), top_k: gemini_value_by_case(generation_config, "topK", "top_k").and_then(Value::as_u64), stop_sequences: gemini_value_by_case(generation_config, "stopSequences", "stop_sequences") .and_then(openai_stop_to_vec), n: gemini_value_by_case(generation_config, "candidateCount", "candidate_count") .and_then(Value::as_u64), seed: generation_config.get("seed").and_then(Value::as_i64), ..CanonicalGenerationConfig::default() } } pub(crate) fn gemini_thinking_to_canonical( value: Option<&Value>, ) -> Option { let generation_config = value.and_then(Value::as_object)?; let thinking_config = gemini_value_by_case(generation_config, "thinkingConfig", "thinking_config") .and_then(Value::as_object)?; let budget_tokens = thinking_config .get("thinkingBudget") .or_else(|| thinking_config.get("thinking_budget")) .and_then(Value::as_u64); let thinking_level = thinking_config .get("thinkingLevel") .or_else(|| thinking_config.get("thinking_level")) .and_then(Value::as_str) .and_then(ReasoningEffort::parse) .map(ReasoningEffort::as_openai_chat_value); let mut extensions = BTreeMap::new(); extensions.insert( "gemini".to_string(), json!({ "thinking_config": Value::Object(thinking_config.clone()) }), ); if let Some(reasoning_effort) = budget_tokens .map(map_thinking_budget_to_openai_reasoning_effort) .or(thinking_level) { extensions.insert( "openai".to_string(), json!({ "reasoning_effort": reasoning_effort }), ); } Some(CanonicalThinkingConfig { enabled: thinking_config .get("includeThoughts") .or_else(|| thinking_config.get("include_thoughts")) .and_then(Value::as_bool) .unwrap_or(true), budget_tokens, extensions, }) } pub(crate) fn gemini_response_format_to_canonical( value: Option<&Value>, ) -> Option { let generation_config = value.and_then(Value::as_object)?; let response_mime_type = gemini_value_by_case(generation_config, "responseMimeType", "response_mime_type") .and_then(Value::as_str)?; if response_mime_type != "application/json" { return None; } let json_schema = gemini_value_by_case(generation_config, "responseSchema", "response_schema") .map(|schema| { json!({ "name": "response_schema", "schema": schema, }) }); Some(CanonicalResponseFormat { format_type: if json_schema.is_some() { "json_schema".to_string() } else { "json_object".to_string() }, json_schema, extensions: BTreeMap::new(), }) } pub(crate) type GeminiCanonicalTools = ( Vec, Vec, Option, Option, Option, ); #[derive(Clone)] pub(crate) struct GeminiGoogleSearchGrounding { pub source_field: &'static str, pub source_dialect: &'static str, pub legacy: bool, pub payload: Value, pub raw_payload: Value, pub output_payload: Value, } impl fmt::Debug for GeminiGoogleSearchGrounding { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("GeminiGoogleSearchGrounding") .field("source_field", &self.source_field) .field("source_dialect", &self.source_dialect) .field("legacy", &self.legacy) .field("payload_bytes", &debug_json_bytes(&self.payload)) .field("raw_payload_bytes", &debug_json_bytes(&self.raw_payload)) .field( "output_payload_bytes", &debug_json_bytes(&self.output_payload), ) .finish() } } pub(crate) fn gemini_google_search_grounding( tool_object: &Map, ) -> Option { for (field, source_dialect, legacy) in [ ("googleSearch", "gemini_current", false), ("google_search", "gemini_current", false), ("googleSearchRetrieval", "vertex_legacy", true), ("google_search_retrieval", "vertex_legacy", true), ] { if let Some(raw_payload) = tool_object.get(field) { let raw_payload = normalize_gemini_tool_payload(raw_payload); let payload = lower_camelize_json_object_keys(&raw_payload); let output_payload = if legacy { json!({}) } else { payload.clone() }; return Some(GeminiGoogleSearchGrounding { source_field: field, source_dialect, legacy, payload, raw_payload, output_payload, }); } } None } pub(crate) fn gemini_google_search_grounding_extension( grounding: &GeminiGoogleSearchGrounding, ) -> Value { json!({ "enabled": true, "source_field": grounding.source_field, "source_dialect": grounding.source_dialect, "payload": grounding.payload, "raw_payload": grounding.raw_payload, "legacy": grounding.legacy, }) } fn normalize_gemini_tool_payload(payload: &Value) -> Value { match payload { Value::Null => json!({}), value => value.clone(), } } fn lower_camelize_json_object_keys(value: &Value) -> Value { match value { Value::Object(object) => Value::Object( object .iter() .map(|(key, value)| { ( snake_to_lower_camel(key), lower_camelize_json_object_keys(value), ) }) .collect(), ), Value::Array(items) => { Value::Array(items.iter().map(lower_camelize_json_object_keys).collect()) } other => other.clone(), } } fn snake_to_lower_camel(key: &str) -> String { let mut output = String::with_capacity(key.len()); let mut uppercase_next = false; for character in key.chars() { if character == '_' { uppercase_next = true; continue; } if uppercase_next { for uppercase in character.to_uppercase() { output.push(uppercase); } uppercase_next = false; } else { output.push(character); } } output } fn gemini_builtin_tool_portion(tool_object: &Map) -> Option { let builtin = tool_object .iter() .filter(|(key, _)| { key.as_str() != "functionDeclarations" && key.as_str() != "function_declarations" }) .map(|(key, value)| (key.clone(), value.clone())) .collect::>(); (!builtin.is_empty()).then_some(Value::Object(builtin)) } pub(crate) fn gemini_tools_to_canonical(value: Option<&Value>) -> Option { let Some(value) = value else { return Some((Vec::new(), Vec::new(), None, None, None)); }; let tools = value.as_array()?; let mut canonical = Vec::new(); let mut builtin_tools = Vec::new(); let mut web_search_options = None; let mut google_search_grounding = None; for tool in tools { let tool_object = tool.as_object()?; if let Some(grounding) = gemini_google_search_grounding(tool_object) { web_search_options = Some(json!({})); if google_search_grounding.is_none() { google_search_grounding = Some(gemini_google_search_grounding_extension(&grounding)); } } if let Some(builtin_tool) = gemini_builtin_tool_portion(tool_object) { builtin_tools.push(builtin_tool); } let declarations = tool_object .get("functionDeclarations") .or_else(|| tool_object.get("function_declarations")) .and_then(Value::as_array); let Some(declarations) = declarations else { continue; }; for declaration in declarations { let declaration_object = declaration.as_object()?; let name = declaration_object .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; canonical.push(CanonicalToolDefinition { name: name.to_string(), description: declaration_object .get("description") .and_then(Value::as_str) .map(ToOwned::to_owned), parameters: declaration_object.get("parameters").cloned(), strict: None, extensions: { let mut extensions = gemini_extensions( declaration_object, &["name", "description", "parameters"], ); if let Some(parameters) = declaration_object.get("parameters").cloned() { canonical_extension_object_mut(&mut extensions, "gemini") .insert("raw_parameters".to_string(), parameters); } extensions }, }); } } Some(( canonical, builtin_tools, web_search_options, Some(value.clone()), google_search_grounding, )) } pub(crate) fn gemini_tool_choice_to_canonical( value: Option<&Value>, ) -> Option { let tool_config = value?.as_object()?; let function_config = tool_config .get("functionCallingConfig") .or_else(|| tool_config.get("function_calling_config"))? .as_object()?; if let Some(name) = function_config .get("allowedFunctionNames") .or_else(|| function_config.get("allowed_function_names")) .and_then(Value::as_array) .and_then(|values| values.first()) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) { return Some(CanonicalToolChoice::Tool { name: name.to_string(), }); } match function_config .get("mode") .and_then(Value::as_str) .unwrap_or_default() .trim() .to_ascii_uppercase() .as_str() { "NONE" => Some(CanonicalToolChoice::None), "AUTO" => Some(CanonicalToolChoice::Auto), "ANY" | "REQUIRED" => Some(CanonicalToolChoice::Required), _ => None, } } pub(crate) fn openai_generation_config(request: &Map) -> CanonicalGenerationConfig { CanonicalGenerationConfig { max_tokens: request .get("max_completion_tokens") .or_else(|| request.get("max_tokens")) .and_then(Value::as_u64), temperature: request.get("temperature").and_then(Value::as_f64), top_p: request.get("top_p").and_then(Value::as_f64), top_k: request.get("top_k").and_then(Value::as_u64), stop_sequences: request.get("stop").and_then(openai_stop_to_vec), n: request.get("n").and_then(Value::as_u64), presence_penalty: request.get("presence_penalty").and_then(Value::as_f64), frequency_penalty: request.get("frequency_penalty").and_then(Value::as_f64), seed: request.get("seed").and_then(Value::as_i64), logprobs: request.get("logprobs").and_then(Value::as_bool), top_logprobs: request.get("top_logprobs").and_then(Value::as_u64), } } pub(crate) fn openai_responses_generation_config( request: &Map, ) -> CanonicalGenerationConfig { let mut config = openai_generation_config(request); config.max_tokens = request.get("max_output_tokens").and_then(Value::as_u64); config } pub(crate) fn write_openai_generation_config( output: &mut Map, config: &CanonicalGenerationConfig, ) { if let Some(value) = config.max_tokens { output.insert("max_completion_tokens".to_string(), Value::from(value)); } insert_f64(output, "temperature", config.temperature); insert_f64(output, "top_p", config.top_p); if let Some(value) = config.top_k { output.insert("top_k".to_string(), Value::from(value)); } if let Some(values) = &config.stop_sequences { output.insert( "stop".to_string(), if values.len() == 1 { Value::String(values[0].clone()) } else { Value::Array(values.iter().cloned().map(Value::String).collect()) }, ); } if let Some(value) = config.n { output.insert("n".to_string(), Value::from(value)); } insert_f64(output, "presence_penalty", config.presence_penalty); insert_f64(output, "frequency_penalty", config.frequency_penalty); if let Some(value) = config.seed { output.insert("seed".to_string(), Value::from(value)); } if let Some(value) = config.logprobs { output.insert("logprobs".to_string(), Value::Bool(value)); } if let Some(value) = config.top_logprobs { output.insert("top_logprobs".to_string(), Value::from(value)); } } pub(crate) fn openai_tools_to_canonical( value: Option<&Value>, ) -> Option> { let Some(value) = value else { return Some(Vec::new()); }; let tools = value.as_array()?; let mut canonical = Vec::new(); for tool in tools { let tool_object = tool.as_object()?; let tool_type = tool_object .get("type") .and_then(Value::as_str) .unwrap_or("function") .trim() .to_ascii_lowercase(); if tool_type == "function" { let function = tool_object.get("function").and_then(Value::as_object)?; canonical.push(CanonicalToolDefinition { name: function.get("name").and_then(Value::as_str)?.to_string(), description: function .get("description") .and_then(Value::as_str) .map(ToOwned::to_owned), parameters: function.get("parameters").cloned(), strict: function.get("strict").and_then(Value::as_bool), extensions: openai_extensions(tool_object, &["type", "function"]), }); } else if tool_type == "custom" { let custom = tool_object.get("custom").and_then(Value::as_object)?; let name = custom .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; canonical.push(CanonicalToolDefinition { name: name.to_string(), description: custom .get("description") .and_then(Value::as_str) .map(ToOwned::to_owned), parameters: custom.get("parameters").cloned(), strict: custom.get("strict").and_then(Value::as_bool), extensions: BTreeMap::from([("openai".to_string(), tool.clone())]), }); } else { return None; } } Some(canonical) } pub(crate) fn openai_responses_tools_to_canonical( value: Option<&Value>, ) -> Option> { let Some(value) = value else { return Some(Vec::new()); }; let tools = value.as_array()?; let mut canonical = Vec::new(); for tool in tools { let tool_object = tool.as_object()?; let tool_type = tool_object .get("type") .and_then(Value::as_str) .unwrap_or("function") .trim() .to_ascii_lowercase(); if tool_type == "function" { if let Some(function) = tool_object.get("function").and_then(Value::as_object) { let name = function .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; let mut extensions = openai_responses_extensions(tool_object, &["type", "function"]); let function_extensions = openai_responses_extensions( function, &["name", "description", "parameters", "strict"], ); merge_tool_extensions(&mut extensions, function_extensions); canonical.push(CanonicalToolDefinition { name: name.to_string(), description: function .get("description") .and_then(Value::as_str) .map(ToOwned::to_owned), parameters: function.get("parameters").cloned(), strict: function.get("strict").and_then(Value::as_bool), extensions, }); continue; } let name = tool_object .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty())?; canonical.push(CanonicalToolDefinition { name: name.to_string(), description: tool_object .get("description") .and_then(Value::as_str) .map(ToOwned::to_owned), parameters: tool_object.get("parameters").cloned(), strict: tool_object.get("strict").and_then(Value::as_bool), extensions: openai_responses_extensions( tool_object, &["type", "name", "description", "parameters", "strict"], ), }); } else if tool_type == "custom" { let custom = tool_object.get("custom").and_then(Value::as_object); let name = tool_object .get("name") .and_then(Value::as_str) .or_else(|| { custom .and_then(|value| value.get("name")) .and_then(Value::as_str) }) .map(str::trim) .filter(|value| !value.is_empty())?; let description = tool_object .get("description") .and_then(Value::as_str) .or_else(|| { custom .and_then(|value| value.get("description")) .and_then(Value::as_str) }) .map(ToOwned::to_owned); let parameters = tool_object .get("parameters") .or_else(|| custom.and_then(|value| value.get("parameters"))) .filter(|value| value.is_object()) .cloned(); canonical.push(CanonicalToolDefinition { name: name.to_string(), description, parameters, strict: tool_object.get("strict").and_then(Value::as_bool), extensions: BTreeMap::from([( OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(), tool.clone(), )]), }); } else if tool_type.starts_with("web_search") { canonical.push(CanonicalToolDefinition { name: tool_type, description: None, parameters: None, strict: None, extensions: BTreeMap::from([( OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(), tool.clone(), )]), }); } else { let name = tool_object .get("name") .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or(tool_type.as_str()); canonical.push(CanonicalToolDefinition { name: name.to_string(), description: tool_object .get("description") .and_then(Value::as_str) .map(ToOwned::to_owned), parameters: None, strict: None, extensions: BTreeMap::from([( OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(), tool.clone(), )]), }); } } Some(canonical) } fn merge_tool_extensions(target: &mut BTreeMap, source: BTreeMap) { for (namespace, value) in source { match (target.get_mut(&namespace), value) { (Some(Value::Object(target)), Value::Object(source)) => { target.extend(source); } (_, value) => { target.insert(namespace, value); } } } } pub(crate) fn canonical_tool_to_openai(tool: &CanonicalToolDefinition) -> Value { if let Some(raw) = tool .extensions .get("openai") .filter(|value| openai_tool_raw_type_is(value, "custom")) { return raw.clone(); } if let Some(raw) = tool .extensions .get(OPENAI_RESPONSES_EXTENSION_NAMESPACE) .or_else(|| { tool.extensions .get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE) }) .filter(|value| openai_tool_raw_type_is(value, "custom")) { return openai_responses_custom_tool_to_chat_tool(tool, raw); } let mut function = Map::new(); function.insert("name".to_string(), Value::String(tool.name.clone())); if let Some(description) = &tool.description { function.insert( "description".to_string(), Value::String(description.clone()), ); } if let Some(parameters) = &tool.parameters { function.insert("parameters".to_string(), parameters.clone()); } if let Some(strict) = tool.strict { function.insert("strict".to_string(), Value::Bool(strict)); } json!({ "type": "function", "function": Value::Object(function), }) } pub(crate) fn canonical_tool_is_openai_custom(tool: &CanonicalToolDefinition) -> bool { tool.extensions .get("openai") .or_else(|| tool.extensions.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)) .or_else(|| { tool.extensions .get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE) }) .is_some_and(|value| openai_tool_raw_type_is(value, "custom")) } fn openai_tool_raw_type_is(value: &Value, expected_type: &str) -> bool { value .get("type") .and_then(Value::as_str) .is_some_and(|tool_type| tool_type.eq_ignore_ascii_case(expected_type)) } fn openai_responses_custom_tool_to_chat_tool(tool: &CanonicalToolDefinition, raw: &Value) -> Value { let mut custom = raw .get("custom") .and_then(Value::as_object) .cloned() .unwrap_or_else(|| { raw.as_object() .map(|object| { object .iter() .filter(|(key, _)| key.as_str() != "type") .map(|(key, value)| (key.clone(), value.clone())) .collect() }) .unwrap_or_default() }); custom .entry("name".to_string()) .or_insert_with(|| Value::String(tool.name.clone())); if let Some(description) = &tool.description { custom .entry("description".to_string()) .or_insert_with(|| Value::String(description.clone())); } json!({ "type": "custom", "custom": Value::Object(custom), }) } pub(crate) fn openai_tool_choice_to_canonical( value: Option<&Value>, ) -> Option { match value { Some(Value::String(value)) => match value.as_str() { "auto" => Some(CanonicalToolChoice::Auto), "none" => Some(CanonicalToolChoice::None), "required" => Some(CanonicalToolChoice::Required), _ => None, }, Some(Value::Object(object)) => object .get("function") .and_then(Value::as_object) .and_then(|function| function.get("name")) .and_then(Value::as_str) .or_else(|| { object .get("custom") .and_then(Value::as_object) .and_then(|custom| custom.get("name")) .and_then(Value::as_str) }) .map(str::trim) .filter(|value| !value.is_empty()) .map(|name| CanonicalToolChoice::Tool { name: name.to_string(), }), _ => None, } } pub(crate) fn openai_responses_tool_choice_to_canonical( value: Option<&Value>, ) -> Option { match value { Some(Value::String(value)) => match value.as_str() { "auto" => Some(CanonicalToolChoice::Auto), "none" => Some(CanonicalToolChoice::None), "required" => Some(CanonicalToolChoice::Required), _ => None, }, Some(Value::Object(object)) => { let choice_type = object .get("type") .and_then(Value::as_str) .unwrap_or_default() .trim() .to_ascii_lowercase(); if choice_type == "function" { object .get("name") .and_then(Value::as_str) .or_else(|| { object .get("function") .and_then(Value::as_object) .and_then(|function| function.get("name")) .and_then(Value::as_str) }) .map(str::trim) .filter(|value| !value.is_empty()) .map(|name| CanonicalToolChoice::Tool { name: name.to_string(), }) } else if choice_type == "custom" { object .get("name") .and_then(Value::as_str) .or_else(|| { object .get("custom") .and_then(Value::as_object) .and_then(|custom| custom.get("name")) .and_then(Value::as_str) }) .map(str::trim) .filter(|value| !value.is_empty()) .map(|name| CanonicalToolChoice::Tool { name: name.to_string(), }) } else { None } } _ => None, } } pub(crate) fn canonical_tool_choice_to_openai(choice: &CanonicalToolChoice) -> Value { match choice { CanonicalToolChoice::Auto => Value::String("auto".to_string()), CanonicalToolChoice::None => Value::String("none".to_string()), CanonicalToolChoice::Required => Value::String("required".to_string()), CanonicalToolChoice::Tool { name } => json!({ "type": "function", "function": { "name": name }, }), } } pub(crate) fn openai_tool_choice_raw_to_chat(value: &Value) -> Value { let Some(object) = value.as_object() else { return value.clone(); }; match object .get("type") .and_then(Value::as_str) .unwrap_or_default() .trim() .to_ascii_lowercase() .as_str() { "function" => { raw_named_tool_choice_to_chat(object, "function").unwrap_or_else(|| value.clone()) } "custom" => { raw_named_tool_choice_to_chat(object, "custom").unwrap_or_else(|| value.clone()) } "allowed_tools" => raw_allowed_tool_choice_to_chat(object), _ => value.clone(), } } pub(crate) fn openai_tool_choice_raw_to_responses(value: &Value) -> Value { let Some(object) = value.as_object() else { return value.clone(); }; match object .get("type") .and_then(Value::as_str) .unwrap_or_default() .trim() .to_ascii_lowercase() .as_str() { "function" => { raw_named_tool_choice_to_responses(object, "function").unwrap_or_else(|| value.clone()) } "custom" => { raw_named_tool_choice_to_responses(object, "custom").unwrap_or_else(|| value.clone()) } "allowed_tools" => raw_allowed_tool_choice_to_responses(object), _ => value.clone(), } } fn raw_named_tool_choice_to_chat(object: &Map, tool_type: &str) -> Option { let name = raw_tool_choice_name(object, tool_type)?; let mut named_tool = Map::new(); named_tool.insert("name".to_string(), Value::String(name)); let mut out = Map::new(); out.insert("type".to_string(), Value::String(tool_type.to_string())); out.insert(tool_type.to_string(), Value::Object(named_tool)); Some(Value::Object(out)) } fn raw_named_tool_choice_to_responses( object: &Map, tool_type: &str, ) -> Option { let name = raw_tool_choice_name(object, tool_type)?; Some(json!({ "type": tool_type, "name": name, })) } fn raw_tool_choice_name(object: &Map, tool_type: &str) -> Option { object .get("name") .and_then(Value::as_str) .or_else(|| { object .get(tool_type) .and_then(Value::as_object) .and_then(|tool| tool.get("name")) .and_then(Value::as_str) }) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn raw_allowed_tool_choice_to_chat(object: &Map) -> Value { let mut allowed = object .get("allowed_tools") .and_then(Value::as_object) .cloned() .unwrap_or_else(|| raw_allowed_tool_choice_payload(object)); if let Some(tools) = allowed.get("tools").and_then(Value::as_array) { allowed.insert( "tools".to_string(), Value::Array(tools.iter().map(raw_allowed_tool_to_chat).collect()), ); } json!({ "type": "allowed_tools", "allowed_tools": Value::Object(allowed), }) } fn raw_allowed_tool_choice_to_responses(object: &Map) -> Value { let mut out = object .get("allowed_tools") .and_then(Value::as_object) .cloned() .unwrap_or_else(|| raw_allowed_tool_choice_payload(object)); out.insert( "type".to_string(), Value::String("allowed_tools".to_string()), ); if let Some(tools) = out.get("tools").and_then(Value::as_array) { out.insert( "tools".to_string(), Value::Array(tools.iter().map(raw_allowed_tool_to_responses).collect()), ); } Value::Object(out) } fn raw_allowed_tool_choice_payload(object: &Map) -> Map { object .iter() .filter(|(key, _)| key.as_str() != "type") .map(|(key, value)| (key.clone(), value.clone())) .collect() } fn raw_allowed_tool_to_chat(value: &Value) -> Value { let Some(object) = value.as_object() else { return value.clone(); }; let tool_type = object .get("type") .and_then(Value::as_str) .unwrap_or_default() .trim() .to_ascii_lowercase(); if tool_type == "function" || tool_type == "custom" { raw_named_tool_choice_to_chat(object, &tool_type).unwrap_or_else(|| value.clone()) } else { value.clone() } } fn raw_allowed_tool_to_responses(value: &Value) -> Value { let Some(object) = value.as_object() else { return value.clone(); }; let tool_type = object .get("type") .and_then(Value::as_str) .unwrap_or_default() .trim() .to_ascii_lowercase(); if tool_type == "function" || tool_type == "custom" { raw_named_tool_choice_to_responses(object, &tool_type).unwrap_or_else(|| value.clone()) } else { value.clone() } } pub(crate) fn canonical_instructions_to_claude_system( instructions: &[CanonicalInstruction], ) -> Option { let instructions = instructions .iter() .filter(|instruction| !instruction.text.trim().is_empty()) .collect::>(); if instructions.is_empty() { return None; } let mut blocks = Vec::new(); let mut has_structured_extensions = false; for instruction in &instructions { let mut block = Map::new(); block.insert("type".to_string(), Value::String("text".to_string())); block.insert("text".to_string(), Value::String(instruction.text.clone())); let extra = namespace_extension_object(&instruction.extensions, "claude", &block); if !extra.is_empty() { has_structured_extensions = true; block.extend(extra); } blocks.push(Value::Object(block)); } if has_structured_extensions { Some(Value::Array(blocks)) } else { Some(Value::String( instructions .iter() .map(|instruction| instruction.text.as_str()) .collect::>() .join("\n\n"), )) } } pub(crate) fn canonical_messages_to_claude(canonical: &CanonicalRequest) -> Option> { let mut messages = Vec::new(); for message in &canonical.messages { let role = match message.role { CanonicalRole::Assistant => "assistant", CanonicalRole::Tool => "user", CanonicalRole::System | CanonicalRole::Developer => continue, CanonicalRole::Unknown | CanonicalRole::User => "user", }; let blocks = canonical_blocks_to_claude(&message.content, message.role.clone())?; if blocks.is_empty() { continue; } messages.push(json!({ "role": role, "content": simplify_canonical_claude_content(blocks), })); } Some(messages) } pub(crate) fn canonical_blocks_to_claude( blocks: &[CanonicalContentBlock], role: CanonicalRole, ) -> Option> { let mut out = Vec::new(); for block in blocks { if let Some(value) = canonical_block_to_claude(block, &role)? { out.push(value); } } Some(out) } pub(crate) fn canonical_block_to_claude( block: &CanonicalContentBlock, role: &CanonicalRole, ) -> Option> { match block { CanonicalContentBlock::Text { text, extensions } => { if text.trim().is_empty() { return Some(None); } let mut out = Map::new(); out.insert("type".to_string(), Value::String("text".to_string())); out.insert("text".to_string(), Value::String(text.clone())); out.extend(namespace_extension_object( extensions, CLAUDE_EXTENSION_NAMESPACE, &out, )); Some(Some(Value::Object(out))) } CanonicalContentBlock::Thinking { text, signature, encrypted_content, extensions, } => { if let Some(data) = encrypted_content .as_ref() .filter(|value| !value.is_empty()) .filter(|_| is_claude_thinking_block(extensions)) { let mut out = Map::new(); out.insert( "type".to_string(), Value::String("redacted_thinking".to_string()), ); out.insert("data".to_string(), Value::String(data.clone())); out.extend(namespace_extension_object( extensions, CLAUDE_EXTENSION_NAMESPACE, &out, )); return Some(Some(Value::Object(out))); } if !matches!(role, CanonicalRole::Assistant) { if text.trim().is_empty() { return Some(None); } return Some(Some(json!({ "type": "text", "text": text, }))); } if text.trim().is_empty() { return Some(None); } let mut out = Map::new(); out.insert("type".to_string(), Value::String("thinking".to_string())); out.insert("thinking".to_string(), Value::String(text.clone())); if let Some(signature) = signature.as_ref().filter(|value| !value.is_empty()) { out.insert("signature".to_string(), Value::String(signature.clone())); } out.extend(namespace_extension_object( extensions, CLAUDE_EXTENSION_NAMESPACE, &out, )); Some(Some(Value::Object(out))) } CanonicalContentBlock::Image { data, url, media_type, extensions, .. } => { if !matches!( role, CanonicalRole::User | CanonicalRole::Tool | CanonicalRole::Unknown ) { return Some(Some(json!({ "type": "text", "text": assistant_image_placeholder(url.as_deref(), data.is_some()), }))); } let mut out = Map::new(); out.insert("type".to_string(), Value::String("image".to_string())); out.insert( "source".to_string(), claude_source_value(media_type.as_deref(), data.as_deref(), url.as_deref())?, ); out.extend(namespace_extension_object( extensions, CLAUDE_EXTENSION_NAMESPACE, &out, )); Some(Some(Value::Object(out))) } CanonicalContentBlock::File { data, file_id, file_url, media_type, extensions, .. } => { if let Some(file_id) = file_id.as_ref().filter(|value| !value.is_empty()) { return Some(Some(json!({ "type": "text", "text": format!("[File: {file_id}]"), }))); } let mut out = Map::new(); out.insert("type".to_string(), Value::String("document".to_string())); out.insert( "source".to_string(), claude_source_value(media_type.as_deref(), data.as_deref(), file_url.as_deref())?, ); out.extend(namespace_extension_object( extensions, CLAUDE_EXTENSION_NAMESPACE, &out, )); Some(Some(Value::Object(out))) } CanonicalContentBlock::Audio { data, media_type, format, extensions, } => { let fallback_media_type = format.as_ref().map(|value| format!("audio/{value}")); let mut out = Map::new(); out.insert("type".to_string(), Value::String("document".to_string())); out.insert( "source".to_string(), claude_source_value( media_type.as_deref().or(fallback_media_type.as_deref()), data.as_deref(), None, )?, ); out.extend(namespace_extension_object( extensions, CLAUDE_EXTENSION_NAMESPACE, &out, )); Some(Some(Value::Object(out))) } CanonicalContentBlock::ToolUse { id, name, input, extensions, } => { let input = remove_empty_pages_from_tool_input_value(name, input); let mut out = Map::new(); out.insert("type".to_string(), Value::String("tool_use".to_string())); out.insert( "id".to_string(), Value::String(claude_compatible_tool_use_id(id)), ); out.insert("name".to_string(), Value::String(name.clone())); out.insert("input".to_string(), input); out.extend(namespace_extension_object( extensions, CLAUDE_EXTENSION_NAMESPACE, &out, )); Some(Some(Value::Object(out))) } CanonicalContentBlock::ToolResult { tool_use_id, output, content_text, is_error, extensions, .. } => { let mut out = Map::new(); out.insert("type".to_string(), Value::String("tool_result".to_string())); out.insert( "tool_use_id".to_string(), Value::String(claude_compatible_tool_use_id(tool_use_id)), ); out.insert( "content".to_string(), canonical_tool_result_content_to_claude( output.as_ref(), content_text.as_deref(), role, extensions, ), ); if *is_error { out.insert("is_error".to_string(), Value::Bool(true)); } out.extend(namespace_extension_object( extensions, CLAUDE_EXTENSION_NAMESPACE, &out, )); Some(Some(Value::Object(out))) } CanonicalContentBlock::Unknown { payload, extensions, .. } if is_claude_raw_block(extensions) => Some(Some(payload.clone())), CanonicalContentBlock::Unknown { .. } => Some(None), } } fn canonical_tool_result_content_to_claude( output: Option<&Value>, content_text: Option<&str>, role: &CanonicalRole, extensions: &BTreeMap, ) -> Value { if matches!(role, CanonicalRole::Assistant) { return output .cloned() .unwrap_or_else(|| Value::String(content_text.unwrap_or_default().to_string())); } if is_openai_chat_tool_result(extensions) { let text = content_text .map(ToOwned::to_owned) .or_else(|| output.map(openai_responses_tool_output_text)) .unwrap_or_default(); return Value::String(non_empty_tool_result_text(&text)); } match output { Some(Value::String(text)) => Value::String(non_empty_tool_result_text(text)), Some(Value::Array(parts)) if claude_tool_result_content_blocks_are_wire_safe(parts) => { Value::Array(parts.clone()) } Some(Value::Null) => Value::String(non_empty_tool_result_text("")), Some(value) => serde_json::to_string(value) .map(|text| Value::String(non_empty_tool_result_text(&text))) .unwrap_or_else(|_| { Value::String(non_empty_tool_result_text(content_text.unwrap_or_default())) }), None => Value::String(non_empty_tool_result_text(content_text.unwrap_or_default())), } } fn is_openai_chat_tool_result(extensions: &BTreeMap) -> bool { extensions .get(AETHER_EXTENSION_NAMESPACE) .and_then(|value| value.get("source")) .and_then(Value::as_str) == Some(OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER) } fn non_empty_tool_result_text(text: &str) -> String { if text.trim().is_empty() { "(empty)".to_string() } else { text.to_string() } } fn claude_compatible_tool_use_id(id: &str) -> String { let trimmed = id.trim(); if trimmed.is_empty() { return "toolu_".to_string(); } if trimmed.starts_with("toolu_") || trimmed.starts_with("call_") { trimmed.to_string() } else { format!("toolu_{trimmed}") } } fn claude_tool_result_content_blocks_are_wire_safe(parts: &[Value]) -> bool { !parts.is_empty() && parts.iter().all(|part| { part.as_object() .and_then(|object| object.get("type")) .and_then(Value::as_str) .is_some_and(|block_type| { matches!(block_type, "text" | "image" | "document" | "file") }) }) } pub(crate) fn claude_source_value( media_type: Option<&str>, data: Option<&str>, url: Option<&str>, ) -> Option { if let Some(data) = data.filter(|value| !value.is_empty()) { return Some(json!({ "type": "base64", "media_type": media_type.unwrap_or("application/octet-stream"), "data": data, })); } url.filter(|value| !value.is_empty()).map(|url| { json!({ "type": "url", "url": url, }) }) } pub(crate) fn simplify_canonical_claude_content(blocks: Vec) -> Value { if blocks.is_empty() { return Value::String(String::new()); } let mut text_values = Vec::new(); for block in &blocks { let Some(block_object) = block.as_object() else { return Value::Array(blocks); }; if block_object.len() == 2 && block_object.get("type").and_then(Value::as_str) == Some("text") { if let Some(text) = block_object.get("text").and_then(Value::as_str) { text_values.push(text.to_string()); continue; } } return Value::Array(blocks); } Value::String(text_values.join("\n")) } pub(crate) fn compact_canonical_claude_messages(messages: Vec) -> Vec { let mut compact: Vec = Vec::new(); for message in messages { let role = message .get("role") .and_then(Value::as_str) .unwrap_or_default() .to_string(); if let Some(last) = compact.last_mut() { let last_role = last .get("role") .and_then(Value::as_str) .unwrap_or_default() .to_string(); if last_role == role { merge_canonical_claude_message_content(last, message); continue; } } compact.push(message); } if compact .first() .and_then(|value| value.get("role")) .and_then(Value::as_str) .is_some_and(|value| value == "assistant") { compact.insert(0, json!({ "role": "user", "content": "" })); } compact } pub(crate) fn merge_canonical_claude_message_content(target: &mut Value, message: Value) { let Some(target_object) = target.as_object_mut() else { return; }; let incoming_content = message.get("content").cloned().unwrap_or(Value::Null); let merged_blocks = extract_canonical_claude_content_blocks(target_object.get("content")) .into_iter() .chain(extract_canonical_claude_content_blocks(Some( &incoming_content, ))) .collect::>(); target_object.insert( "content".to_string(), simplify_canonical_claude_content(merged_blocks), ); } pub(crate) fn extract_canonical_claude_content_blocks(content: Option<&Value>) -> Vec { match content { Some(Value::String(text)) if !text.is_empty() => vec![json!({ "type": "text", "text": text, })], Some(Value::Array(blocks)) => blocks.clone(), _ => Vec::new(), } } pub(crate) fn canonical_tools_to_claude(canonical: &CanonicalRequest) -> Vec { let mut tools = canonical .tools .iter() .map(|tool| { let mut out = Map::new(); out.insert("name".to_string(), Value::String(tool.name.clone())); if let Some(description) = &tool.description { out.insert( "description".to_string(), Value::String(description.clone()), ); } out.insert( "input_schema".to_string(), tool.extensions .get("claude") .and_then(Value::as_object) .and_then(|value| value.get("raw_input_schema")) .cloned() .unwrap_or_else(|| { claude_input_schema_from_tool_parameters(tool.parameters.as_ref()) }), ); let mut extra = namespace_extension_object(&tool.extensions, "claude", &out); extra.remove("raw_input_schema"); out.extend(extra); Value::Object(out) }) .collect::>(); if let Some(builtin_tools) = canonical .extensions .get("claude") .and_then(Value::as_object) .and_then(|value| value.get("builtin_tools")) .and_then(Value::as_array) { tools.extend(builtin_tools.iter().cloned()); } tools } fn claude_input_schema_from_tool_parameters(parameters: Option<&Value>) -> Value { match parameters { Some(Value::Object(schema)) => { let mut schema = schema.clone(); schema .entry("type".to_string()) .or_insert_with(|| Value::String("object".to_string())); schema .entry("properties".to_string()) .or_insert_with(|| json!({})); Value::Object(schema) } Some(Value::Null) | None => json!({"type": "object", "properties": {}}), Some(_) => json!({"type": "object", "properties": {}}), } } pub(crate) fn canonical_tool_choice_to_claude( choice: Option<&CanonicalToolChoice>, parallel_tool_calls: Option, ) -> Option { let mut out = match choice { Some(CanonicalToolChoice::None) => Some(json!({ "type": "none" })), Some(CanonicalToolChoice::Required) => Some(json!({ "type": "any" })), Some(CanonicalToolChoice::Auto) => Some(json!({ "type": "auto" })), Some(CanonicalToolChoice::Tool { name }) => Some(json!({ "type": "tool", "name": name, })), None => parallel_tool_calls.map(|_| json!({ "type": "auto" })), }?; if let Some(parallel_tool_calls) = parallel_tool_calls { if let Some(object) = out.as_object_mut() { if object.get("type").and_then(Value::as_str) != Some("none") { object.insert( "disable_parallel_tool_use".to_string(), Value::Bool(!parallel_tool_calls), ); } } } Some(out) } pub(crate) fn apply_gemini_request_extensions( output: &mut Value, extensions: &BTreeMap, ) -> Option<()> { let Some(gemini) = extensions.get("gemini").and_then(Value::as_object) else { return Some(()); }; let output_object = output.as_object_mut()?; let thinking_config = gemini.get("thinking_config").cloned(); let response_modalities = gemini.get("response_modalities").cloned(); let generation_config_extra = gemini .get("generation_config_extra") .and_then(Value::as_object) .cloned(); if thinking_config.is_some() || response_modalities.is_some() || generation_config_extra.is_some() { let generation_config = output_object .entry("generationConfig".to_string()) .or_insert_with(|| Value::Object(Map::new())) .as_object_mut()?; if let Some(thinking_config) = thinking_config { generation_config.insert("thinkingConfig".to_string(), thinking_config); } if let Some(response_modalities) = response_modalities { generation_config.insert("responseModalities".to_string(), response_modalities); } if let Some(extra) = generation_config_extra { for (key, value) in extra { generation_config.entry(key).or_insert(value); } } } if let Some(safety_settings) = gemini.get("safety_settings").cloned() { output_object.insert("safetySettings".to_string(), safety_settings); } if let Some(cached_content) = gemini.get("cached_content").cloned() { output_object.insert("cachedContent".to_string(), cached_content); } if let Some(raw_tools) = gemini.get("raw_tools").cloned() { if should_reuse_raw_gemini_tools(gemini) { output_object.insert("tools".to_string(), raw_tools); } else { output_object .entry("tools".to_string()) .or_insert(raw_tools); } } if let Some(raw_tool_config) = gemini.get("raw_tool_config").cloned() { output_object.insert("toolConfig".to_string(), raw_tool_config); } for (key, value) in gemini { if GEMINI_REQUEST_EXTENSION_INTERNAL_KEYS.contains(&key.as_str()) { continue; } output_object .entry(key.clone()) .or_insert_with(|| value.clone()); } Some(()) } const GEMINI_REQUEST_EXTENSION_INTERNAL_KEYS: &[&str] = &[ "builtin_tools", "cached_content", "generation_config_extra", "grounding", "raw_tool_config", "raw_tools", "response_modalities", "safety_settings", "thinking_config", ]; fn should_reuse_raw_gemini_tools(gemini: &Map) -> bool { let Some(google_search) = gemini .get("grounding") .and_then(Value::as_object) .and_then(|grounding| grounding.get("google_search")) .and_then(Value::as_object) else { return true; }; google_search .get("legacy") .and_then(Value::as_bool) .is_none_or(|legacy| !legacy) && google_search .get("source_field") .and_then(Value::as_str) .is_some_and(|source_field| source_field == "googleSearch") } pub(crate) fn assistant_image_placeholder(url: Option<&str>, has_data: bool) -> String { match (url, has_data) { (Some(url), false) if !url.trim().is_empty() => format!("[Image: {url}]"), _ => "[Image]".to_string(), } } pub(crate) fn openai_response_format_to_canonical( value: Option<&Value>, ) -> Option { let object = value?.as_object()?; let format_type = object .get("type") .and_then(Value::as_str) .unwrap_or("text") .to_string(); let json_schema = openai_response_format_json_schema(object, &format_type); let excluded_fields = if json_schema.is_some() && format_type.eq_ignore_ascii_case("json_schema") { &[ "type", "json_schema", "name", "description", "schema", "strict", ][..] } else { &["type", "json_schema"][..] }; Some(CanonicalResponseFormat { format_type, json_schema, extensions: openai_extensions(object, excluded_fields), }) } fn openai_response_format_json_schema( object: &Map, format_type: &str, ) -> Option { if let Some(json_schema) = object.get("json_schema").cloned() { return Some(json_schema); } if !format_type.eq_ignore_ascii_case("json_schema") { return None; } let mut schema = Map::new(); for field in ["name", "description", "schema", "strict"] { if let Some(value) = object.get(field) { schema.insert(field.to_string(), value.clone()); } } (!schema.is_empty()).then_some(Value::Object(schema)) } pub(crate) fn canonical_response_format_to_openai(value: &CanonicalResponseFormat) -> Value { let mut output = Map::new(); output.insert("type".to_string(), Value::String(value.format_type.clone())); if let Some(json_schema) = &value.json_schema { output.insert("json_schema".to_string(), json_schema.clone()); } Value::Object(output) } pub(crate) fn canonical_response_format_to_openai_responses( value: &CanonicalResponseFormat, ) -> Value { let mut output = Map::new(); output.insert("type".to_string(), Value::String(value.format_type.clone())); if let Some(json_schema) = &value.json_schema { if value.format_type.eq_ignore_ascii_case("json_schema") { if let Some(schema_object) = json_schema.as_object() { output.extend(schema_object.clone()); } else { output.insert("schema".to_string(), json_schema.clone()); } } else { output.insert("json_schema".to_string(), json_schema.clone()); } } Value::Object(output) } pub(crate) fn openai_usage_to_canonical(value: Option<&Value>) -> Option { let usage = value?.as_object()?; let input_tokens = usage .get("prompt_tokens") .or_else(|| usage.get("input_tokens")) .and_then(Value::as_u64) .unwrap_or(0); let output_tokens = usage .get("completion_tokens") .or_else(|| usage.get("output_tokens")) .and_then(Value::as_u64) .unwrap_or(0); let reasoning_tokens = usage .get("completion_tokens_details") .or_else(|| usage.get("output_tokens_details")) .and_then(Value::as_object) .and_then(|details| details.get("reasoning_tokens")) .and_then(Value::as_u64) .unwrap_or(0); let cache_read_tokens = usage .get("prompt_tokens_details") .or_else(|| usage.get("input_tokens_details")) .and_then(Value::as_object) .and_then(|details| details.get("cached_tokens")) .and_then(Value::as_u64) .unwrap_or(0); let cache_write_tokens = usage .get("prompt_tokens_details") .or_else(|| usage.get("input_tokens_details")) .and_then(Value::as_object) .and_then(|details| { details .get("cache_write_tokens") .or_else(|| details.get("cached_creation_tokens")) .or_else(|| details.get("cache_creation_tokens")) }) .and_then(Value::as_u64) .unwrap_or(0); Some(CanonicalUsage { input_tokens, input_tokens_include_cache: cache_read_tokens > 0 || cache_write_tokens > 0, output_tokens, total_tokens: usage .get("total_tokens") .and_then(Value::as_u64) .unwrap_or(input_tokens.saturating_add(output_tokens)), cache_read_tokens, cache_write_tokens, reasoning_tokens, extensions: openai_extensions( usage, &["prompt_tokens", "completion_tokens", "total_tokens"], ), ..CanonicalUsage::default() }) } pub(crate) fn openai_responses_usage_to_canonical(value: Option<&Value>) -> Option { let usage = value?.as_object()?; let mut canonical = openai_usage_to_canonical(value)?; let provider_fields = usage .iter() .filter(|(key, _)| { !matches!( key.as_str(), "input_tokens" | "output_tokens" | "total_tokens" ) }) .map(|(key, value)| { let value = if key == "input_tokens_details" { let mut details = value.as_object().cloned().unwrap_or_default(); details.remove("cached_creation_tokens"); details.remove("cache_creation_tokens"); Value::Object(details) } else { value.clone() }; (key.clone(), value) }) .collect::>(); canonical.extensions = if provider_fields.is_empty() { BTreeMap::new() } else { BTreeMap::from([( OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(), Value::Object(provider_fields), )]) }; Some(canonical) } pub(crate) fn claude_usage_to_canonical(value: Option<&Value>) -> Option { let usage = value?.as_object()?; let input_tokens = usage .get("input_tokens") .and_then(Value::as_u64) .unwrap_or(0); let output_tokens = usage .get("output_tokens") .and_then(Value::as_u64) .unwrap_or(0); let cache_read_tokens = usage .get("cache_read_input_tokens") .and_then(Value::as_u64) .unwrap_or(0); let cache_write_tokens = usage .get("cache_creation_input_tokens") .and_then(Value::as_u64) .unwrap_or(0); let reasoning_tokens = usage .get("output_tokens_details") .and_then(Value::as_object) .and_then(|details| { details .get("thinking_tokens") .or_else(|| details.get("reasoning_tokens")) }) .or_else(|| usage.get("reasoning_tokens")) .and_then(Value::as_u64) .unwrap_or(0); Some(CanonicalUsage { input_tokens, input_tokens_include_cache: false, output_tokens, total_tokens: input_tokens.saturating_add(output_tokens), cache_read_tokens, cache_write_tokens, cache_creation_ephemeral_5m_tokens: usage .get("cache_creation") .and_then(Value::as_object) .and_then(|value| value.get("ephemeral_5m_input_tokens")) .and_then(Value::as_u64) .unwrap_or(0), cache_creation_ephemeral_1h_tokens: usage .get("cache_creation") .and_then(Value::as_object) .and_then(|value| value.get("ephemeral_1h_input_tokens")) .and_then(Value::as_u64) .unwrap_or(0), extensions: claude_extensions( usage, &[ "input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens", "cache_creation", "output_tokens_details", "reasoning_tokens", ], ), reasoning_tokens, }) } pub(crate) fn gemini_usage_to_canonical(value: Option<&Value>) -> Option { let usage = value?.as_object()?; let input_tokens = usage .get("promptTokenCount") .or_else(|| usage.get("prompt_token_count")) .and_then(Value::as_u64) .unwrap_or(0); let visible_output_tokens = usage .get("candidatesTokenCount") .or_else(|| usage.get("candidates_token_count")) .and_then(Value::as_u64) .unwrap_or(0); let reasoning_tokens = usage .get("thoughtsTokenCount") .or_else(|| usage.get("thoughts_token_count")) .and_then(Value::as_u64) .unwrap_or(0); let cache_read_tokens = usage .get("cachedContentTokenCount") .or_else(|| usage.get("cached_content_token_count")) .and_then(Value::as_u64) .unwrap_or(0); let output_tokens = visible_output_tokens + reasoning_tokens; Some(CanonicalUsage { input_tokens, input_tokens_include_cache: cache_read_tokens > 0, output_tokens, total_tokens: usage .get("totalTokenCount") .or_else(|| usage.get("total_token_count")) .and_then(Value::as_u64) .unwrap_or(input_tokens + output_tokens), cache_read_tokens, reasoning_tokens, extensions: gemini_extensions( usage, &[ "promptTokenCount", "prompt_token_count", "cachedContentTokenCount", "cached_content_token_count", "candidatesTokenCount", "candidates_token_count", "thoughtsTokenCount", "thoughts_token_count", "totalTokenCount", "total_token_count", ], ), ..CanonicalUsage::default() }) } pub(crate) fn canonical_usage_to_openai(value: &CanonicalUsage) -> Value { let input_tokens = canonical_usage_total_input_tokens(value); let total_tokens = canonical_usage_total_tokens_for_inclusive_input(value, input_tokens); let mut output = json!({ "prompt_tokens": input_tokens, "completion_tokens": value.output_tokens, "total_tokens": total_tokens, }); if value.reasoning_tokens > 0 { output["completion_tokens_details"] = json!({ "reasoning_tokens": value.reasoning_tokens, }); } if value.cache_read_tokens > 0 { output["prompt_tokens_details"] = json!({ "cached_tokens": value.cache_read_tokens, }); } if value.cache_write_tokens > 0 { if output.get("prompt_tokens_details").is_none() { output["prompt_tokens_details"] = json!({}); } output["prompt_tokens_details"]["cache_write_tokens"] = Value::from(value.cache_write_tokens); } output } pub(crate) fn canonical_usage_to_openai_responses_usage(value: &CanonicalUsage) -> Value { let input_tokens = canonical_usage_total_input_tokens(value); let total_tokens = canonical_usage_total_tokens_for_inclusive_input(value, input_tokens); let mut output = json!({ "input_tokens": input_tokens, "output_tokens": value.output_tokens, "total_tokens": total_tokens, }); if value.reasoning_tokens > 0 { output["output_tokens_details"] = json!({ "reasoning_tokens": value.reasoning_tokens, }); } if value.cache_read_tokens > 0 { output["input_tokens_details"] = json!({ "cached_tokens": value.cache_read_tokens, }); } if value.cache_write_tokens > 0 { if output.get("input_tokens_details").is_none() { output["input_tokens_details"] = json!({}); } output["input_tokens_details"]["cache_write_tokens"] = Value::from(value.cache_write_tokens); } if let (Some(output), Some(provider_fields)) = ( output.as_object_mut(), openai_responses_extension(&value.extensions).and_then(Value::as_object), ) { merge_json_object_missing(output, provider_fields); } output } fn merge_json_object_missing(target: &mut Map, source: &Map) { for (key, source_value) in source { match (target.get_mut(key), source_value) { (Some(Value::Object(target_object)), Value::Object(source_object)) => { merge_json_object_missing(target_object, source_object); } (Some(_), _) => {} (None, _) => { target.insert(key.clone(), source_value.clone()); } } } } pub(crate) fn canonical_usage_to_claude(value: &CanonicalUsage) -> Value { let mut output = json!({ "input_tokens": canonical_usage_uncached_input_tokens(value), "output_tokens": value.output_tokens, }); if value.cache_read_tokens > 0 { output["cache_read_input_tokens"] = Value::from(value.cache_read_tokens); } if value.cache_write_tokens > 0 { output["cache_creation_input_tokens"] = Value::from(value.cache_write_tokens); } if value.cache_creation_ephemeral_5m_tokens > 0 || value.cache_creation_ephemeral_1h_tokens > 0 { output["cache_creation"] = json!({ "ephemeral_5m_input_tokens": value.cache_creation_ephemeral_5m_tokens, "ephemeral_1h_input_tokens": value.cache_creation_ephemeral_1h_tokens, }); } if value.reasoning_tokens > 0 { output["output_tokens_details"] = json!({ "thinking_tokens": value.reasoning_tokens, }); } output } fn canonical_usage_cache_creation_tokens(value: &CanonicalUsage) -> u64 { if value.cache_write_tokens > 0 { value.cache_write_tokens } else { value .cache_creation_ephemeral_5m_tokens .saturating_add(value.cache_creation_ephemeral_1h_tokens) } } fn canonical_usage_cache_input_tokens(value: &CanonicalUsage) -> u64 { value .cache_read_tokens .saturating_add(canonical_usage_cache_creation_tokens(value)) } fn canonical_usage_uncached_input_tokens(value: &CanonicalUsage) -> u64 { if value.input_tokens_include_cache { value .input_tokens .saturating_sub(canonical_usage_cache_input_tokens(value)) } else { value.input_tokens } } pub(crate) fn canonical_usage_total_input_tokens(value: &CanonicalUsage) -> u64 { if value.input_tokens_include_cache { value.input_tokens } else { value .input_tokens .saturating_add(canonical_usage_cache_input_tokens(value)) } } pub(crate) fn canonical_usage_total_tokens_for_inclusive_input( value: &CanonicalUsage, input_tokens: u64, ) -> u64 { if value.total_tokens > 0 && (value.input_tokens_include_cache || canonical_usage_cache_input_tokens(value) == 0) { value.total_tokens } else { input_tokens.saturating_add(value.output_tokens) } } pub(crate) fn openai_finish_reason_to_canonical( value: Option<&str>, ) -> Option { Some(match value? { "stop" => CanonicalStopReason::EndTurn, "length" => CanonicalStopReason::MaxTokens, "tool_calls" | "function_call" => CanonicalStopReason::ToolUse, "content_filter" => CanonicalStopReason::ContentFiltered, _ => CanonicalStopReason::Unknown, }) } pub(crate) fn claude_stop_reason_to_canonical(value: Option<&str>) -> Option { Some(match value? { "end_turn" => CanonicalStopReason::EndTurn, "max_tokens" => CanonicalStopReason::MaxTokens, "stop_sequence" => CanonicalStopReason::StopSequence, "tool_use" => CanonicalStopReason::ToolUse, "pause_turn" => CanonicalStopReason::PauseTurn, "refusal" => CanonicalStopReason::Refusal, "content_filtered" => CanonicalStopReason::ContentFiltered, _ => CanonicalStopReason::Unknown, }) } pub(crate) fn gemini_stop_reason_to_canonical(value: &str) -> Option { Some(match value.trim().to_ascii_uppercase().as_str() { "STOP" => CanonicalStopReason::EndTurn, "MAX_TOKENS" => CanonicalStopReason::MaxTokens, "SAFETY" | "RECITATION" | "LANGUAGE" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII" | "IMAGE_SAFETY" | "IMAGE_PROHIBITED_CONTENT" | "IMAGE_RECITATION" => CanonicalStopReason::ContentFiltered, "OTHER" => CanonicalStopReason::Unknown, _ => CanonicalStopReason::Unknown, }) } pub(crate) fn canonical_stop_reason_to_openai(value: Option<&CanonicalStopReason>) -> &'static str { match value { Some(CanonicalStopReason::MaxTokens) => "length", Some(CanonicalStopReason::ToolUse) => "tool_calls", Some(CanonicalStopReason::ContentFiltered | CanonicalStopReason::Refusal) => { "content_filter" } _ => "stop", } } pub(crate) fn canonical_stop_reason_to_claude(value: Option<&CanonicalStopReason>) -> &'static str { match value { Some(CanonicalStopReason::MaxTokens) => "max_tokens", Some(CanonicalStopReason::StopSequence) => "stop_sequence", Some(CanonicalStopReason::ToolUse) => "tool_use", Some(CanonicalStopReason::PauseTurn) => "pause_turn", Some(CanonicalStopReason::Refusal) => "refusal", Some(CanonicalStopReason::ContentFiltered) => "content_filtered", _ => "end_turn", } } pub(crate) fn openai_stop_to_vec(value: &Value) -> Option> { match value { Value::String(text) => Some(vec![text.clone()]), Value::Array(items) => Some( items .iter() .filter_map(Value::as_str) .map(ToOwned::to_owned) .collect(), ), _ => None, } } pub(crate) fn parse_jsonish_value(value: Option<&Value>) -> Value { match value { Some(Value::String(text)) => { serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.clone())) } Some(value) => value.clone(), None => json!({}), } } pub(crate) fn openai_responses_tool_output_text(value: &Value) -> String { match value { Value::String(text) => text.clone(), other => other.to_string(), } } pub(crate) fn canonicalize_tool_arguments(value: &Value) -> String { match value { Value::String(text) => text.clone(), _ => value.to_string(), } } pub(crate) fn split_data_url( value: Option, fallback_media_type: Option, ) -> (Option, Option, Option) { let Some(value) = value else { return (fallback_media_type, None, None); }; if let Some(rest) = value.strip_prefix("data:") { if let Some((media_type, data)) = rest.split_once(";base64,") { return (Some(media_type.to_string()), Some(data.to_string()), None); } } (fallback_media_type, None, Some(value)) } pub(crate) fn media_data_or_url( media_type: &Option, data: &Option, url: &Option, ) -> String { if let Some(data) = data { return format!( "data:{};base64,{}", media_type.as_deref().unwrap_or("application/octet-stream"), data ); } url.clone().unwrap_or_default() } pub(crate) fn offset_openai_annotation_indices(annotation: &Value, offset: i64) -> Value { let Some(object) = annotation.as_object() else { return annotation.clone(); }; let mut adjusted = object.clone(); for key in [ "start_index", "end_index", "start_char", "end_char", "index", ] { if let Some(value) = adjusted.get(key).and_then(Value::as_i64) { adjusted.insert(key.to_string(), Value::from(value + offset)); } } Value::Object(adjusted) } pub(crate) fn insert_f64(output: &mut Map, key: &str, value: Option) { if let Some(value) = value { if let Some(number) = serde_json::Number::from_f64(value) { output.insert(key.to_string(), Value::Number(number)); } } } pub(crate) fn openai_extensions( object: &Map, handled_keys: &[&str], ) -> BTreeMap { let handled = handled_keys .iter() .copied() .collect::>(); let raw = object .iter() .filter(|(key, _)| !handled.contains(key.as_str())) .map(|(key, value)| (key.clone(), value.clone())) .collect::>(); if raw.is_empty() { BTreeMap::new() } else { BTreeMap::from([("openai".to_string(), Value::Object(raw))]) } } pub(crate) fn claude_extensions( object: &Map, handled_keys: &[&str], ) -> BTreeMap { let handled = handled_keys .iter() .copied() .collect::>(); let raw = object .iter() .filter(|(key, _)| !handled.contains(key.as_str())) .map(|(key, value)| (key.clone(), value.clone())) .collect::>(); if raw.is_empty() { BTreeMap::new() } else { BTreeMap::from([("claude".to_string(), Value::Object(raw))]) } } pub(crate) fn openai_responses_extensions( object: &Map, handled_keys: &[&str], ) -> BTreeMap { let mut extensions = openai_extensions(object, handled_keys); if let Some(raw) = extensions.remove("openai") { extensions.insert(OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(), raw); } extensions } pub(crate) fn gemini_extensions( object: &Map, handled_keys: &[&str], ) -> BTreeMap { let handled = handled_keys .iter() .copied() .collect::>(); let raw = object .iter() .filter(|(key, _)| !handled.contains(key.as_str())) .map(|(key, value)| (key.clone(), value.clone())) .collect::>(); if raw.is_empty() { BTreeMap::new() } else { BTreeMap::from([("gemini".to_string(), Value::Object(raw))]) } } const GEMINI_MAPPED_GENERATION_CONFIG_KEYS: &[&str] = &[ "maxOutputTokens", "max_output_tokens", "temperature", "topP", "top_p", "topK", "top_k", "candidateCount", "candidate_count", "seed", "stopSequences", "stop_sequences", "thinkingConfig", "thinking_config", "responseMimeType", "response_mime_type", "responseSchema", "response_schema", "responseModalities", "response_modalities", ]; pub(crate) fn gemini_value_by_case<'a>( object: &'a Map, camel: &str, snake: &str, ) -> Option<&'a Value> { object.get(camel).or_else(|| object.get(snake)) } pub(crate) fn gemini_generation_config_extra( generation_config: &Map, ) -> Map { generation_config .iter() .filter(|(key, _)| { !GEMINI_MAPPED_GENERATION_CONFIG_KEYS .iter() .any(|candidate| candidate == &key.as_str()) }) .map(|(key, value)| (key.clone(), value.clone())) .collect() } pub(crate) fn extract_gemini_model_from_path(path: &str) -> Option { let marker = "/models/"; let start = path.find(marker)? + marker.len(); let tail = &path[start..]; let end = tail.find(':').unwrap_or(tail.len()); let model = tail[..end].trim(); if model.is_empty() { None } else { Some(model.to_string()) } } pub(crate) fn canonical_extension_object_mut<'a>( extensions: &'a mut BTreeMap, namespace: &str, ) -> &'a mut Map { let entry = extensions .entry(namespace.to_string()) .or_insert_with(|| Value::Object(Map::new())); if !entry.is_object() { *entry = Value::Object(Map::new()); } entry.as_object_mut().expect("extension namespace object") } pub(crate) fn namespace_extension_object( extensions: &BTreeMap, namespace: &str, existing: &Map, ) -> Map { extensions .get(namespace) .and_then(Value::as_object) .map(|object| { object .iter() .filter(|(key, _)| !existing.contains_key(*key)) .map(|(key, value)| (key.clone(), value.clone())) .collect() }) .unwrap_or_default() } pub(crate) fn openai_responses_extension(extensions: &BTreeMap) -> Option<&Value> { extensions .get(OPENAI_RESPONSES_EXTENSION_NAMESPACE) .or_else(|| extensions.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)) } pub(crate) fn openai_service_tier_extension( extensions: &BTreeMap, ) -> Option<&Value> { [ OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE, "openai", ] .into_iter() .find_map(|namespace| { extensions .get(namespace) .and_then(Value::as_object) .and_then(|object| object.get("service_tier")) }) } pub(crate) fn openai_responses_item_extension_object( extensions: &BTreeMap, existing: &Map, ) -> Map { openai_responses_extension(extensions) .and_then(Value::as_object) .map(|object| { object .iter() .filter(|(key, _)| { !existing.contains_key(*key) && !matches!(key.as_str(), "item_id" | "item_type") }) .map(|(key, value)| (key.clone(), value.clone())) .collect() }) .unwrap_or_default() } pub(crate) fn strip_claude_billing_header(text: &str) -> String { let trimmed = text.trim(); let prefix = "x-anthropic-billing-header:"; if !trimmed.to_ascii_lowercase().starts_with(prefix) { return trimmed.to_string(); } let remainder = trimmed .split_once('\n') .map(|(_, rest)| rest.trim_start()) .unwrap_or_default(); remainder.trim_start_matches('\n').trim().to_string() } fn rerank_document_is_empty(value: &Value) -> bool { match value { Value::String(text) => text.trim().is_empty(), Value::Object(object) => object .get("text") .and_then(Value::as_str) .is_some_and(|text| text.trim().is_empty()), Value::Null => true, _ => false, } } #[cfg(test)] mod tests { use super::{ canonical_request_unknown_block_count, canonical_response_unknown_block_count, canonical_to_claude_request, canonical_to_claude_response, canonical_to_gemini_request, canonical_to_gemini_response, canonical_to_openai_chat_request, canonical_to_openai_chat_response, canonical_to_openai_responses_request, canonical_to_openai_responses_response, canonical_unknown_block_count, from_claude_to_canonical_request, from_claude_to_canonical_response, from_gemini_to_canonical_request, from_gemini_to_canonical_response, from_openai_chat_to_canonical_request, from_openai_chat_to_canonical_response, from_openai_responses_to_canonical_request, from_openai_responses_to_canonical_response, CanonicalContentBlock, CanonicalEmbedding, CanonicalEmbeddingContent, CanonicalEmbeddingInput, CanonicalEmbeddingRequest, CanonicalRole, CanonicalUsage, }; use serde_json::{json, Value}; #[test] fn canonical_embedding_request_accepts_axonhub_input_shapes() { for input in [ json!("hello"), json!(["hello", "world"]), json!([1, 2, 3]), json!([[1, 2], [3, 4]]), ] { let request = json!({ "model": "text-embedding-3-small", "embedding": { "input": input, "encoding_format": "float", "dimensions": 3 } }); let canonical = serde_json::from_value::(request) .expect("embedding request should deserialize"); assert!(canonical.embedding.is_some()); assert!(canonical.messages.is_empty()); let encoded = serde_json::to_value(&canonical).expect("serialize"); assert!(encoded.get("messages").is_some()); assert!(encoded.get("embedding").is_some()); } } #[test] fn embedding_wire_request_accepts_all_axonhub_input_shapes() { let cases = [ ( json!("hello"), "single string", CanonicalEmbeddingInput::String("hello".to_string()), ), ( json!(["hello", "world"]), "string array", CanonicalEmbeddingInput::StringArray(vec![ "hello".to_string(), "world".to_string(), ]), ), ( json!([1, 2, 3]), "token array", CanonicalEmbeddingInput::TokenArray(vec![1, 2, 3]), ), ( json!([[1, 2], [3, 4]]), "nested token array", CanonicalEmbeddingInput::TokenArrayArray(vec![vec![1, 2], vec![3, 4]]), ), ( json!([ {"text": "white running shoes"}, {"image": "https://example.com/shoe.png"}, {"video": "https://example.com/demo.mp4"}, {"multi_images": ["https://example.com/a.png", "https://example.com/b.png"]} ]), "multimodal array", CanonicalEmbeddingInput::Multimodal(vec![ CanonicalEmbeddingContent { text: Some("white running shoes".to_string()), image: None, video: None, multi_images: None, }, CanonicalEmbeddingContent { text: None, image: Some("https://example.com/shoe.png".to_string()), video: None, multi_images: None, }, CanonicalEmbeddingContent { text: None, image: None, video: Some("https://example.com/demo.mp4".to_string()), multi_images: None, }, CanonicalEmbeddingContent { text: None, image: None, video: None, multi_images: Some(vec![ "https://example.com/a.png".to_string(), "https://example.com/b.png".to_string(), ]), }, ]), ), ]; for (input, label, expected_input) in cases { let body = json!({ "model": "text-embedding-3-small", "input": input }); let canonical = super::from_embedding_to_canonical_request(&body, "openai") .unwrap_or_else(|| panic!("{label} should parse")); assert_eq!( canonical.embedding.expect("embedding request").input, expected_input, "{label} should preserve its canonical variant" ); assert!(canonical.messages.is_empty()); } } #[test] fn embedding_wire_request_rejects_empty_invalid_or_chat_payloads() { for body in [ json!({"model": "text-embedding-3-small", "input": " "}), json!({"model": "text-embedding-3-small", "input": []}), json!({"model": "text-embedding-3-small", "input": [1, "two"]}), json!({"model": "text-embedding-3-small", "input": [[1], []]}), json!({"model": "text-embedding-3-small", "input": [{"image": " "}]}), json!({"model": "text-embedding-3-small", "input": [{"multi_images": []}]}), json!({"model": "text-embedding-3-small", "input": ["hello", {"image": "https://example.com/a.png"}]}), json!({"model": "", "input": "hello"}), json!({"input": "hello"}), json!({"model": "text-embedding-3-small", "messages": []}), ] { assert!( super::from_embedding_to_canonical_request(&body, "openai").is_none(), "invalid embedding payload should be rejected: {body}" ); } } #[test] fn embedding_openai_request_response_roundtrip_stays_non_chat() { let body = json!({ "model": "text-embedding-3-small", "input": ["hello", "world"], "encoding_format": "float", "dimensions": 2, "user": "user-1", "extra": true }); let canonical = super::from_embedding_to_canonical_request(&body, "openai").expect("embedding request"); assert_eq!(canonical.model, "text-embedding-3-small"); assert!(canonical.messages.is_empty()); assert!(matches!( canonical.embedding.as_ref().map(|embedding| &embedding.input), Some(CanonicalEmbeddingInput::StringArray(values)) if values == &vec!["hello".to_string(), "world".to_string()] )); let rebuilt = super::canonical_to_embedding_request(&canonical, "upstream-embedding", "openai") .expect("openai embedding request"); assert_eq!(rebuilt["model"], "upstream-embedding"); assert_eq!(rebuilt["input"], json!(["hello", "world"])); assert!(rebuilt.get("messages").is_none()); let response = json!({ "object": "list", "model": "upstream-embedding", "data": [ {"object": "embedding", "index": 0, "embedding": [0.1, 0.2]}, {"object": "embedding", "index": 1, "embedding": [0.3, 0.4]} ], "usage": {"prompt_tokens": 4, "total_tokens": 4} }); let canonical_response = super::from_embedding_to_canonical_response(&response, "openai") .expect("embedding response"); assert_eq!(canonical_response.embeddings.len(), 2); let emitted = super::canonical_to_embedding_response(&canonical_response, "openai") .expect("embedding response output"); assert_eq!(emitted["data"][0]["embedding"], json!([0.1, 0.2])); assert!(emitted.get("choices").is_none()); assert!(emitted.get("messages").is_none()); } #[test] fn embedding_provider_request_emitters_preserve_provider_contracts() { let canonical = super::CanonicalRequest { model: "text-embedding-3-small".to_string(), embedding: Some(CanonicalEmbeddingRequest { input: CanonicalEmbeddingInput::StringArray(vec![ "alpha".to_string(), "beta".to_string(), ]), encoding_format: Some("float".to_string()), dimensions: Some(2), task: None, user: None, parameters: None, extensions: Default::default(), }), ..Default::default() }; let jina = super::canonical_to_embedding_request(&canonical, "jina-embeddings-v3", "jina") .expect("jina embedding request"); assert_eq!(jina["task"], "text-matching"); assert_eq!(jina["input"], json!(["alpha", "beta"])); let gemini = super::canonical_to_embedding_request(&canonical, "gemini-embedding-001", "gemini") .expect("gemini embedding request"); assert!(gemini.get("model").is_none()); assert_eq!( gemini["requests"][0]["model"], "models/gemini-embedding-001" ); assert_eq!( gemini["requests"][0]["content"]["parts"][0]["text"], "alpha" ); assert_eq!(gemini["requests"][0]["outputDimensionality"], 2); assert!(gemini.get("messages").is_none()); let doubao = super::canonical_to_embedding_request( &canonical, "doubao-embedding-text-240515", "doubao", ) .expect("doubao embedding request"); assert_eq!(doubao["input"], json!(["alpha", "beta"])); assert!(doubao.get("messages").is_none()); } #[test] fn embedding_provider_request_emitters_cover_golden_payload_variants() { let single = super::CanonicalRequest { model: "text-embedding-3-small".to_string(), embedding: Some(CanonicalEmbeddingRequest { input: CanonicalEmbeddingInput::String("alpha".to_string()), encoding_format: Some("float".to_string()), dimensions: Some(1536), task: Some("retrieval.passage".to_string()), user: Some("user-1".to_string()), parameters: None, extensions: Default::default(), }), ..Default::default() }; let openai = super::canonical_to_embedding_request(&single, "text-embedding-3-large", "openai") .expect("openai embedding request"); assert_eq!(openai["model"], "text-embedding-3-large"); assert_eq!(openai["input"], "alpha"); assert_eq!(openai["encoding_format"], "float"); assert_eq!(openai["dimensions"], 1536); assert_eq!(openai["user"], "user-1"); assert_eq!(openai["task"], "retrieval.passage"); let jina = super::canonical_to_embedding_request(&single, "jina-embeddings-v3", "jina") .expect("jina embedding request"); assert_eq!(jina["task"], "retrieval.passage"); assert_eq!(jina["input"], "alpha"); let gemini = super::canonical_to_embedding_request(&single, "gemini-embedding-001", "gemini") .expect("gemini single embedding request"); assert_eq!(gemini["model"], "gemini-embedding-001"); assert_eq!(gemini["content"]["parts"][0]["text"], "alpha"); assert!(gemini.get("requests").is_none()); let doubao = super::canonical_to_embedding_request( &single, "doubao-embedding-text-240515", "doubao", ) .expect("doubao embedding request"); assert_eq!(doubao["model"], "doubao-embedding-text-240515"); assert_eq!(doubao["input"], json!(["alpha"])); assert_eq!(doubao["dimensions"], 1536); } #[test] fn gemini_and_doubao_embedding_emitters_reject_token_inputs() { for input in [ CanonicalEmbeddingInput::TokenArray(vec![1, 2, 3]), CanonicalEmbeddingInput::TokenArrayArray(vec![vec![1, 2], vec![3, 4]]), ] { let canonical = super::CanonicalRequest { model: "token-model".to_string(), embedding: Some(CanonicalEmbeddingRequest { input, encoding_format: None, dimensions: None, task: None, user: None, parameters: None, extensions: Default::default(), }), ..Default::default() }; assert!(super::canonical_to_embedding_request( &canonical, "gemini-embedding-001", "gemini" ) .is_none()); assert!(super::canonical_to_embedding_request( &canonical, "doubao-embedding", "doubao" ) .is_none()); } } #[test] fn embedding_response_parser_rejects_error_and_malformed_vectors() { for body in [ json!({"error": {"message": "bad"}}), json!({"object": "list"}), json!({"data": [{"object": "embedding", "embedding": [0.1, "bad"]}]}), json!({"data": [{"object": "embedding"}]}), ] { assert!( super::from_embedding_to_canonical_response(&body, "openai").is_none(), "malformed embedding response should be rejected: {body}" ); } } #[test] fn embedding_response_parser_uses_openai_fallback_fields() { let body = json!({ "object": "list", "data": [ {"object": "embedding", "embedding": [0.1, 0.2]}, {"object": "embedding", "index": 7, "embedding": [0.3, 0.4]} ] }); let canonical = super::from_embedding_to_canonical_response(&body, "openai") .expect("fallback embedding response"); assert_eq!(canonical.id, "embd-unknown"); assert_eq!(canonical.model, "unknown"); assert_eq!(canonical.embeddings[0].index, 0); assert_eq!(canonical.embeddings[1].index, 7); assert!(super::canonical_to_embedding_response(&canonical, "jina").is_some()); assert!(super::canonical_to_embedding_response(&canonical, "gemini").is_none()); assert!(super::canonical_to_embedding_response(&canonical, "doubao").is_none()); } #[test] fn embedding_response_contract_serializes_vectors_without_chat_outputs() { let response = super::CanonicalEmbeddingResponse { id: "embd-1".to_string(), model: "text-embedding-3-small".to_string(), embeddings: vec![CanonicalEmbedding { index: 0, embedding: vec![0.1, 0.2, 0.3], extensions: Default::default(), }], usage: Some(CanonicalUsage { input_tokens: 3, total_tokens: 3, ..Default::default() }), extensions: Default::default(), }; let encoded = serde_json::to_value(&response).expect("serialize"); assert_eq!( encoded["embeddings"][0]["embedding"], json!([0.1, 0.2, 0.3]) ); assert!(encoded.get("choices").is_none()); let decoded = serde_json::from_value::(encoded) .expect("deserialize"); assert_eq!(decoded, response); } #[test] fn canonical_request_preserves_openai_multimodal_tools_and_extensions() { let request = json!({ "model": "gpt-5", "messages": [ {"role": "system", "content": "Be exact.", "cache_control": {"type": "ephemeral"}}, {"role": "developer", "content": [{"type": "text", "text": "Prefer short answers."}]}, {"role": "user", "content": [ {"type": "text", "text": "inspect this"}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgo=", "detail": "high"}}, {"type": "file", "file": {"file_data": "data:application/pdf;base64,JVBERi0x", "filename": "a.pdf"}}, {"type": "input_audio", "input_audio": {"data": "AAAA", "format": "mp3"}}, {"type": "future_part", "value": 1} ]}, {"role": "assistant", "content": null, "tool_calls": [{ "id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{\"q\":\"rust\"}"} }]}, {"role": "tool", "tool_call_id": "call_1", "content": "{\"ok\":true}"} ], "tools": [{ "type": "function", "function": {"name": "lookup", "description": "Lookup", "parameters": {"type": "object"}} }], "tool_choice": {"type": "function", "function": {"name": "lookup"}}, "max_completion_tokens": 42, "temperature": 0.2, "reasoning_effort": "high", "metadata": {"trace": "abc"}, "vendor_extra": true }); let canonical = from_openai_chat_to_canonical_request(&request).expect("canonical request"); assert_eq!(canonical.model, "gpt-5"); assert_eq!(canonical.instructions.len(), 2); assert_eq!(canonical.instructions[1].role, CanonicalRole::Developer); assert_eq!(canonical.generation.max_tokens, Some(42)); assert_eq!(canonical.tools[0].name, "lookup"); assert!(canonical.thinking.is_some()); assert!(canonical.extensions.contains_key("openai")); let user_blocks = &canonical.messages[0].content; assert!( matches!(user_blocks[1], CanonicalContentBlock::Image { ref data, ref media_type, .. } if data.as_deref() == Some("iVBORw0KGgo=") && media_type.as_deref() == Some("image/png")) ); assert!( matches!(user_blocks[2], CanonicalContentBlock::File { ref data, ref filename, .. } if data.as_deref() == Some("JVBERi0x") && filename.as_deref() == Some("a.pdf")) ); assert!( matches!(user_blocks[3], CanonicalContentBlock::Audio { ref data, ref format, .. } if data.as_deref() == Some("AAAA") && format.as_deref() == Some("mp3")) ); assert_eq!(canonical_unknown_block_count(user_blocks), 1); assert_eq!(canonical_request_unknown_block_count(&canonical), 1); let rebuilt = canonical_to_openai_chat_request(&canonical).expect("openai chat request"); assert_eq!(rebuilt["model"], "gpt-5"); assert_eq!(rebuilt["messages"][0]["role"], "system"); assert_eq!(rebuilt["messages"][1]["role"], "system"); assert_eq!( rebuilt["messages"][2]["content"][1]["image_url"]["url"], "data:image/png;base64,iVBORw0KGgo=" ); assert_eq!( rebuilt["messages"][3]["tool_calls"][0]["function"]["arguments"], "{\"q\":\"rust\"}" ); assert_eq!(rebuilt["vendor_extra"], true); } #[test] fn openai_chat_request_adapter_preserves_custom_tools_and_choices() { let request = json!({ "model": "gpt-5", "messages": [{"role": "user", "content": "run a custom tool"}], "tools": [{ "type": "custom", "custom": { "name": "shell_command", "description": "Run a shell command", "format": { "type": "text" } } }], "tool_choice": { "type": "custom", "custom": {"name": "shell_command"} } }); let canonical = from_openai_chat_to_canonical_request(&request).expect("canonical request"); assert_eq!(canonical.tools.len(), 1); assert_eq!(canonical.tools[0].name, "shell_command"); assert!(matches!( canonical.tool_choice, Some(super::CanonicalToolChoice::Tool { ref name }) if name == "shell_command" )); let rebuilt = canonical_to_openai_chat_request(&canonical).expect("openai chat request"); assert_eq!(rebuilt["tools"][0]["type"], "custom"); assert_eq!(rebuilt["tools"][0]["custom"]["name"], "shell_command"); assert_eq!(rebuilt["tools"][0]["custom"]["format"]["type"], "text"); assert_eq!(rebuilt["tool_choice"]["type"], "custom"); assert_eq!(rebuilt["tool_choice"]["custom"]["name"], "shell_command"); let responses = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false) .expect("openai responses request"); assert_eq!(responses["tools"][0]["type"], "custom"); assert_eq!(responses["tools"][0]["name"], "shell_command"); assert_eq!(responses["tools"][0]["format"]["type"], "text"); assert_eq!(responses["tool_choice"]["type"], "custom"); assert_eq!(responses["tool_choice"]["name"], "shell_command"); } #[test] fn openai_chat_request_adapter_preserves_allowed_tool_choice_for_responses() { let request = json!({ "model": "gpt-5", "messages": [{"role": "user", "content": "choose an allowed tool"}], "tools": [ { "type": "function", "function": {"name": "lookup_weather", "parameters": {"type": "object"}} }, { "type": "custom", "custom": {"name": "shell_command", "format": {"type": "text"}} } ], "tool_choice": { "type": "allowed_tools", "allowed_tools": { "mode": "required", "tools": [ {"type": "function", "function": {"name": "lookup_weather"}}, {"type": "custom", "custom": {"name": "shell_command"}} ] } } }); let canonical = from_openai_chat_to_canonical_request(&request).expect("canonical request"); assert!(canonical.tool_choice.is_none()); let rebuilt = canonical_to_openai_chat_request(&canonical).expect("openai chat request"); assert_eq!(rebuilt["tool_choice"], request["tool_choice"]); let responses = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false) .expect("openai responses request"); assert_eq!(responses["tool_choice"]["type"], "allowed_tools"); assert_eq!(responses["tool_choice"]["mode"], "required"); assert_eq!(responses["tool_choice"]["tools"][0]["type"], "function"); assert_eq!( responses["tool_choice"]["tools"][0]["name"], "lookup_weather" ); assert_eq!(responses["tool_choice"]["tools"][1]["type"], "custom"); assert_eq!( responses["tool_choice"]["tools"][1]["name"], "shell_command" ); } #[test] fn canonical_response_roundtrips_reasoning_tool_usage_and_unknown_extensions() { let response = json!({ "id": "chatcmpl_1", "object": "chat.completion", "model": "gpt-5", "choices": [{ "index": 0, "message": { "role": "assistant", "reasoning_content": "thinking", "content": [{"type": "text", "text": "done"}], "tool_calls": [{ "id": "call_1", "type": "function", "function": {"name": "lookup", "arguments": "{\"q\":\"rust\"}"} }] }, "finish_reason": "tool_calls" }], "usage": { "prompt_tokens": 3, "completion_tokens": 5, "total_tokens": 8, "prompt_tokens_details": {"cached_tokens": 2}, "completion_tokens_details": {"reasoning_tokens": 1} }, "service_tier": "default" }); let canonical = from_openai_chat_to_canonical_response(&response).expect("canonical response"); assert_eq!(canonical.id, "chatcmpl_1"); assert!( matches!(canonical.content[0], CanonicalContentBlock::Thinking { ref text, .. } if text == "thinking") ); assert!(canonical.content.iter().any( |block| matches!(block, CanonicalContentBlock::ToolUse { name, .. } if name == "lookup") )); assert_eq!(canonical.usage.as_ref().unwrap().cache_read_tokens, 2); assert_eq!(canonical.usage.as_ref().unwrap().reasoning_tokens, 1); assert_eq!(canonical_response_unknown_block_count(&canonical), 0); assert!(canonical.extensions.contains_key("openai")); let encoded = serde_json::to_value(&canonical).expect("serialize"); let decoded = serde_json::from_value::(encoded).expect("deserialize"); assert_eq!(decoded, canonical); let rebuilt = canonical_to_openai_chat_response(&canonical); assert_eq!( rebuilt["choices"][0]["message"]["reasoning_content"], "thinking" ); assert_eq!( rebuilt["choices"][0]["message"]["tool_calls"][0]["function"]["name"], "lookup" ); assert_eq!( rebuilt["usage"]["completion_tokens_details"]["reasoning_tokens"], 1 ); } #[test] fn openai_chat_response_preserves_custom_tool_calls_for_responses() { let response = json!({ "id": "chatcmpl_custom", "object": "chat.completion", "model": "gpt-5", "choices": [{ "index": 0, "message": { "role": "assistant", "content": null, "tool_calls": [{ "id": "call_custom_1", "type": "custom", "custom": { "name": "shell_command", "input": "ls -la" } }] }, "finish_reason": "tool_calls" }] }); let canonical = from_openai_chat_to_canonical_response(&response).expect("canonical response"); assert!(matches!( canonical.content[0], CanonicalContentBlock::ToolUse { ref id, ref name, ref input, .. } if id == "call_custom_1" && name == "shell_command" && input == "ls -la" )); let rebuilt_chat = canonical_to_openai_chat_response(&canonical); assert_eq!( rebuilt_chat["choices"][0]["message"]["tool_calls"][0]["type"], "custom" ); assert_eq!( rebuilt_chat["choices"][0]["message"]["tool_calls"][0]["custom"]["name"], "shell_command" ); let responses = canonical_to_openai_responses_response(&canonical, &json!({})); assert_eq!(responses["output"][0]["type"], "custom_tool_call"); assert_eq!(responses["output"][0]["name"], "shell_command"); assert_eq!(responses["output"][0]["input"], "ls -la"); } #[test] fn openai_responses_response_preserves_custom_tool_calls_for_chat() { let response = json!({ "id": "resp_custom", "object": "response", "status": "completed", "model": "gpt-5", "output": [{ "type": "custom_tool_call", "id": "call_custom_1", "call_id": "call_custom_1", "status": "completed", "name": "shell_command", "input": "pwd" }] }); let canonical = from_openai_responses_to_canonical_response(&response).expect("canonical response"); assert!(matches!( canonical.content[0], CanonicalContentBlock::ToolUse { ref id, ref name, ref input, .. } if id == "call_custom_1" && name == "shell_command" && input == "pwd" )); let chat = canonical_to_openai_chat_response(&canonical); assert_eq!( chat["choices"][0]["message"]["tool_calls"][0]["type"], "custom" ); assert_eq!( chat["choices"][0]["message"]["tool_calls"][0]["custom"]["name"], "shell_command" ); assert_eq!( chat["choices"][0]["message"]["tool_calls"][0]["custom"]["input"], "pwd" ); let rebuilt = canonical_to_openai_responses_response(&canonical, &json!({})); assert_eq!(rebuilt["output"][0]["type"], "custom_tool_call"); assert_eq!(rebuilt["output"][0]["input"], "pwd"); } #[test] fn openai_response_adapters_preserve_audio_output_shapes() { let chat_response = json!({ "id": "chatcmpl_audio", "object": "chat.completion", "model": "gpt-5-audio", "choices": [{ "index": 0, "message": { "role": "assistant", "content": "hello", "audio": { "id": "audio_1", "data": "QUJDRA==", "expires_at": 123, "transcript": "hello" } }, "finish_reason": "stop" }] }); let canonical = from_openai_chat_to_canonical_response(&chat_response).expect("canonical response"); assert!(canonical.content.iter().any( |block| matches!(block, CanonicalContentBlock::Audio { data, .. } if data.as_deref() == Some("QUJDRA==")) )); let rebuilt_chat = canonical_to_openai_chat_response(&canonical); assert_eq!( rebuilt_chat["choices"][0]["message"]["audio"]["id"], "audio_1" ); assert_eq!( rebuilt_chat["choices"][0]["message"]["audio"]["transcript"], "hello" ); let responses = canonical_to_openai_responses_response(&canonical, &json!({})); assert_eq!(responses["output"][0]["content"][1]["type"], "output_audio"); assert_eq!(responses["output"][0]["content"][1]["data"], "QUJDRA=="); assert_eq!(responses["output"][0]["content"][1]["transcript"], "hello"); let responses_response = json!({ "id": "resp_audio", "object": "response", "status": "completed", "model": "gpt-5-audio", "output": [{ "type": "message", "role": "assistant", "content": [{ "type": "output_audio", "data": "RUZHSA==", "transcript": "hi" }] }] }); let canonical = from_openai_responses_to_canonical_response(&responses_response) .expect("canonical response"); let chat = canonical_to_openai_chat_response(&canonical); assert_eq!(chat["choices"][0]["message"]["audio"]["data"], "RUZHSA=="); assert_eq!(chat["choices"][0]["message"]["audio"]["transcript"], "hi"); } #[test] fn canonical_request_adapter_keeps_existing_simple_chat_shape() { let request = json!({ "model": "gpt-5", "messages": [{"role": "user", "content": "hello"}], "stop": ["x", "y"], "n": 2 }); let canonical = from_openai_chat_to_canonical_request(&request).expect("canonical request"); let rebuilt = canonical_to_openai_chat_request(&canonical).expect("openai chat request"); assert_eq!(rebuilt["model"], request["model"]); assert_eq!(rebuilt["messages"], request["messages"]); assert_eq!(rebuilt["stop"], Value::Array(vec![json!("x"), json!("y")])); assert_eq!(rebuilt["n"], 2); } #[test] fn openai_chat_request_adapter_preserves_reasoning_content_for_responses() { let request = json!({ "model": "gpt-5", "messages": [ {"role": "user", "content": "hi"}, { "role": "assistant", "reasoning_content": "internal plan", "content": "final answer" } ] }); let canonical = from_openai_chat_to_canonical_request(&request).expect("canonical request"); assert!(matches!( canonical.messages[1].content.first(), Some(CanonicalContentBlock::Thinking { text, .. }) if text == "internal plan" )); let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false) .expect("openai responses request"); let parts = rebuilt["input"][1]["content"] .as_array() .expect("content parts"); assert_eq!(parts[0]["type"], "output_text"); assert!(parts[0]["text"] .as_str() .expect("reasoning text") .contains("internal plan")); assert_eq!(parts[1]["type"], "output_text"); assert_eq!(parts[1]["text"], "final answer"); } #[test] fn openai_responses_request_adapter_preserves_audio_reasoning_tools_and_text_config() { let request = json!({ "model": "gpt-5", "instructions": "Be exact.", "input": [ { "type": "message", "role": "user", "content": [ {"type": "input_text", "text": "transcribe"}, { "type": "input_audio", "input_audio": { "data": "AAAA", "format": "mp3" } } ] }, { "type": "function_call", "call_id": "call_123", "name": "lookup", "arguments": "{\"q\":\"audio\"}" }, { "type": "function_call_output", "call_id": "call_123", "output": "{\"ok\":true}" } ], "reasoning": {"effort": "high"}, "text": { "format": { "type": "json_schema", "name": "answer", "schema": {"type": "object"}, "strict": true }, "verbosity": "low" }, "tools": [{ "type": "function", "name": "lookup", "parameters": {"type": "object"} }], "tool_choice": {"type": "function", "name": "lookup"} }); let canonical = from_openai_responses_to_canonical_request(&request).expect("canonical request"); assert_eq!(canonical.instructions[0].text, "Be exact."); assert!(matches!( canonical.messages[0].content[1], CanonicalContentBlock::Audio { ref data, ref format, .. } if data.as_deref() == Some("AAAA") && format.as_deref() == Some("mp3") )); assert!(matches!( canonical.messages[1].content[0], CanonicalContentBlock::ToolUse { ref id, ref name, .. } if id == "call_123" && name == "lookup" )); assert_eq!( canonical .thinking .as_ref() .and_then(|thinking| thinking.extensions.get("openai_responses")) .and_then(|value| value.get("effort")) .and_then(Value::as_str), Some("high") ); assert_eq!( canonical .response_format .as_ref() .and_then(|format| format.json_schema.as_ref()) .and_then(|schema| schema.get("name")) .and_then(Value::as_str), Some("answer") ); assert_eq!( canonical .response_format .as_ref() .and_then(|format| format.json_schema.as_ref()) .and_then(|schema| schema.get("strict")) .and_then(Value::as_bool), Some(true) ); let rebuilt_chat = canonical_to_openai_chat_request(&canonical).expect("openai chat request"); assert_eq!(rebuilt_chat["response_format"]["type"], "json_schema"); assert_eq!( rebuilt_chat["response_format"]["json_schema"]["name"], "answer" ); let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false) .expect("openai responses request"); assert_eq!(rebuilt["model"], "gpt-5-upstream"); assert_eq!(rebuilt["text"]["format"]["name"], "answer"); assert_eq!(rebuilt["text"]["format"]["strict"], true); assert!(rebuilt["text"]["format"].get("json_schema").is_none()); assert_eq!(rebuilt["text"]["verbosity"], "low"); assert_eq!(rebuilt["tool_choice"]["name"], "lookup"); } #[test] fn openai_responses_request_adapter_accepts_nested_function_and_custom_tools() { let request = json!({ "model": "gpt-5", "input": "Use a tool", "tools": [ { "type": "function", "function": { "name": "lookup_weather", "description": "Lookup weather", "parameters": { "type": "object", "properties": { "city": {"type": "string"} }, "required": ["city"] } } }, { "type": "custom", "custom": { "name": "shell_command", "description": "Run a shell command" } } ], "tool_choice": { "type": "function", "function": {"name": "lookup_weather"} } }); let canonical = from_openai_responses_to_canonical_request(&request).expect("canonical request"); assert_eq!(canonical.tools.len(), 2); assert_eq!(canonical.tools[0].name, "lookup_weather"); assert_eq!( canonical.tools[0] .parameters .as_ref() .and_then(|value| value.get("required")) .and_then(Value::as_array) .map(Vec::len), Some(1) ); assert_eq!(canonical.tools[1].name, "shell_command"); assert!(matches!( canonical.tool_choice, Some(super::CanonicalToolChoice::Tool { ref name }) if name == "lookup_weather" )); let claude = canonical_to_claude_request(&canonical, "claude-sonnet-4-upstream", false) .expect("claude request"); assert_eq!(claude["tools"][0]["name"], "lookup_weather"); assert_eq!(claude["tools"][1]["name"], "shell_command"); assert_eq!(claude["tool_choice"]["name"], "lookup_weather"); let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false) .expect("openai responses request"); assert_eq!(rebuilt["tools"][0]["name"], "lookup_weather"); assert_eq!(rebuilt["tools"][1]["type"], "custom"); assert_eq!(rebuilt["tools"][1]["custom"]["name"], "shell_command"); } #[test] fn openai_responses_request_adapter_preserves_custom_tool_choice_for_chat() { let request = json!({ "model": "gpt-5", "input": "Use the custom tool", "tools": [{ "type": "custom", "name": "shell_command", "description": "Run a shell command", "format": {"type": "text"} }], "tool_choice": { "type": "custom", "name": "shell_command" } }); let canonical = from_openai_responses_to_canonical_request(&request).expect("canonical request"); assert!(matches!( canonical.tool_choice, Some(super::CanonicalToolChoice::Tool { ref name }) if name == "shell_command" )); let chat = canonical_to_openai_chat_request(&canonical).expect("openai chat request"); assert_eq!(chat["tools"][0]["type"], "custom"); assert_eq!(chat["tools"][0]["custom"]["name"], "shell_command"); assert_eq!(chat["tools"][0]["custom"]["format"]["type"], "text"); assert_eq!(chat["tool_choice"]["type"], "custom"); assert_eq!(chat["tool_choice"]["custom"]["name"], "shell_command"); let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false) .expect("openai responses request"); assert_eq!(rebuilt["tools"][0]["type"], "custom"); assert_eq!(rebuilt["tools"][0]["name"], "shell_command"); assert_eq!(rebuilt["tool_choice"]["type"], "custom"); assert_eq!(rebuilt["tool_choice"]["name"], "shell_command"); } #[test] fn openai_responses_request_adapter_accepts_single_input_item_object() { let request = json!({ "model": "gpt-5", "input": { "type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}] } }); let canonical = from_openai_responses_to_canonical_request(&request).expect("canonical request"); assert_eq!(canonical.messages.len(), 1); assert_eq!(canonical.messages[0].role, CanonicalRole::User); let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false) .expect("openai responses request"); assert_eq!(rebuilt["input"][0]["type"], "message"); assert_eq!(rebuilt["input"][0]["role"], "user"); assert_eq!(rebuilt["input"][0]["content"][0]["text"], "hello"); } #[test] fn openai_responses_request_adapter_preserves_custom_tool_history() { let request = json!({ "model": "gpt-5", "input": [ { "type": "custom_tool_call", "id": "ctc_1", "call_id": "call_custom_1", "name": "shell_command", "input": "ls -la", "status": "completed" }, { "type": "custom_tool_call_output", "call_id": "call_custom_1", "output": "ok" } ] }); let canonical = from_openai_responses_to_canonical_request(&request).expect("canonical request"); let chat = canonical_to_openai_chat_request(&canonical).expect("openai chat request"); assert_eq!(chat["messages"][0]["tool_calls"][0]["type"], "custom"); assert_eq!( chat["messages"][0]["tool_calls"][0]["custom"]["name"], "shell_command" ); assert_eq!( chat["messages"][0]["tool_calls"][0]["custom"]["input"], "ls -la" ); let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false) .expect("openai responses request"); assert_eq!(rebuilt["input"][0]["type"], "custom_tool_call"); assert_eq!(rebuilt["input"][0]["id"], "ctc_1"); assert_eq!(rebuilt["input"][0]["call_id"], "call_custom_1"); assert_eq!(rebuilt["input"][0]["name"], "shell_command"); assert_eq!(rebuilt["input"][0]["input"], "ls -la"); assert_eq!(rebuilt["input"][1]["type"], "custom_tool_call_output"); assert_eq!(rebuilt["input"][1]["output"], "ok"); } #[test] fn openai_responses_request_adapter_preserves_reasoning_encrypted_content_history() { let request = json!({ "model": "gpt-5", "input": [ { "type": "reasoning", "id": "rs_1", "summary": [{"type": "summary_text", "text": "think"}], "encrypted_content": "enc_reasoning" }, { "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "done"}] } ] }); let canonical = from_openai_responses_to_canonical_request(&request).expect("canonical request"); assert!(matches!( canonical.messages[0].content[0], CanonicalContentBlock::Thinking { ref text, ref encrypted_content, .. } if text == "think" && encrypted_content.as_deref() == Some("enc_reasoning") )); let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false) .expect("openai responses request"); assert_eq!(rebuilt["input"][0]["type"], "reasoning"); assert_eq!(rebuilt["input"][0]["content"][0]["type"], "reasoning_text"); assert_eq!(rebuilt["input"][0]["content"][0]["text"], "think"); assert_eq!(rebuilt["input"][0]["encrypted_content"], "enc_reasoning"); assert_eq!(rebuilt["input"][1]["type"], "message"); assert_eq!(rebuilt["input"][1]["content"][0]["text"], "done"); } #[test] fn openai_responses_request_adapter_preserves_file_url_and_tool_output_parts() { let request = json!({ "model": "gpt-5", "messages": [ {"role": "user", "content": [ {"type": "file", "file": {"file_url": "https://example.com/input.pdf", "filename": "input.pdf"}} ]}, {"role": "assistant", "content": null, "tool_calls": [{ "id": "call_render", "type": "function", "function": {"name": "render", "arguments": "{}"} }]}, {"role": "tool", "tool_call_id": "call_render", "content": [ {"type": "text", "text": "Rendered result attached."}, {"type": "image_url", "image_url": {"url": "https://example.com/out.png", "detail": "high"}}, {"type": "file", "file": {"file_url": "https://example.com/report.pdf", "filename": "report.pdf"}} ]} ] }); let canonical = from_openai_chat_to_canonical_request(&request).expect("canonical request"); let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false) .expect("openai responses request"); assert_eq!( rebuilt["input"][0]["content"][0]["file_url"], "https://example.com/input.pdf" ); assert!(rebuilt["input"][0]["content"][0].get("file_data").is_none()); assert_eq!(rebuilt["input"][2]["type"], "function_call_output"); assert_eq!(rebuilt["input"][2]["output"][0]["type"], "input_text"); assert_eq!( rebuilt["input"][2]["output"][1]["image_url"], "https://example.com/out.png" ); assert_eq!(rebuilt["input"][2]["output"][1]["detail"], "high"); assert_eq!( rebuilt["input"][2]["output"][2]["file_url"], "https://example.com/report.pdf" ); } #[test] fn openai_responses_request_adapter_preserves_allowed_tool_choice_for_chat() { let request = json!({ "model": "gpt-5", "input": "choose an allowed tool", "tools": [ {"type": "function", "name": "lookup_weather", "parameters": {"type": "object"}}, {"type": "custom", "name": "shell_command", "format": {"type": "text"}} ], "tool_choice": { "type": "allowed_tools", "mode": "auto", "tools": [ {"type": "function", "name": "lookup_weather"}, {"type": "custom", "name": "shell_command"} ] } }); let canonical = from_openai_responses_to_canonical_request(&request).expect("canonical request"); assert!(canonical.tool_choice.is_none()); let chat = canonical_to_openai_chat_request(&canonical).expect("openai chat request"); assert_eq!(chat["tool_choice"]["type"], "allowed_tools"); assert_eq!(chat["tool_choice"]["allowed_tools"]["mode"], "auto"); assert_eq!( chat["tool_choice"]["allowed_tools"]["tools"][0]["function"]["name"], "lookup_weather" ); assert_eq!( chat["tool_choice"]["allowed_tools"]["tools"][1]["custom"]["name"], "shell_command" ); let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false) .expect("openai responses request"); assert_eq!(rebuilt["tool_choice"], request["tool_choice"]); } #[test] fn openai_responses_response_adapter_preserves_output_items_reasoning_tools_and_usage() { let response = json!({ "id": "resp_123", "object": "response", "status": "completed", "error": null, "model": "gpt-5", "output": [ { "type": "reasoning", "id": "rs_1", "summary": [{ "type": "summary_text", "text": "think" }] }, { "type": "message", "role": "assistant", "content": [ { "type": "output_text", "text": "done", "annotations": [{ "type": "file_citation", "start_index": 1, "end_index": 3 }] }, {"type": "refusal", "refusal": "partial refusal"}, { "type": "output_image", "image_url": "data:image/png;base64,iVBORw0KGgo=" }, { "type": "file", "file": { "file_data": "data:application/pdf;base64,JVBERi0x", "filename": "report.pdf" } } ] }, { "type": "function_call", "id": "call_1", "call_id": "call_1", "name": "lookup", "arguments": "{\"q\":\"rust\"}" }, { "type": "function_call_output", "call_id": "call_1", "output": {"ok": true} }, { "type": "local_shell_call", "id": "lsc_1", "call_id": "call_shell_1", "status": "completed", "action": { "type": "exec", "command": ["pwd"] } }, { "type": "local_shell_call_output", "call_id": "call_shell_1", "output": { "stdout": "/tmp/project\n", "stderr": "", "outcome": "success" } }, { "type": "future_item", "payload": true } ], "usage": { "input_tokens": 3, "input_tokens_details": { "cache_write_tokens": 1, "cached_tokens": 2 }, "output_tokens": 5, "output_tokens_details": {"reasoning_tokens": 1}, "total_tokens": 8 }, "service_tier": "flex" }); let canonical = from_openai_responses_to_canonical_response(&response).expect("canonical response"); assert_eq!(canonical.id, "resp_123"); assert!(matches!( canonical.content[0], CanonicalContentBlock::Thinking { ref text, .. } if text == "think" )); assert!(canonical.content.iter().any( |block| matches!(block, CanonicalContentBlock::ToolUse { name, .. } if name == "lookup") )); assert!(canonical.content.iter().any( |block| matches!(block, CanonicalContentBlock::ToolUse { name, input, .. } if name == "local_shell" && input["action"]["command"][0] == "pwd") )); assert!(canonical.content.iter().any( |block| matches!(block, CanonicalContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == "call_1") )); assert!(canonical.content.iter().any( |block| matches!(block, CanonicalContentBlock::ToolResult { tool_use_id, name: Some(name), .. } if tool_use_id == "call_shell_1" && name == "local_shell") )); assert_eq!(canonical_response_unknown_block_count(&canonical), 2); assert_eq!(canonical.usage.as_ref().unwrap().cache_read_tokens, 2); assert_eq!(canonical.usage.as_ref().unwrap().cache_write_tokens, 1); assert_eq!(canonical.usage.as_ref().unwrap().reasoning_tokens, 1); let rebuilt_chat = canonical_to_openai_chat_response(&canonical); assert_eq!( rebuilt_chat["choices"][0]["message"]["annotations"], json!([{"type": "file_citation", "start_index": 1, "end_index": 3}]) ); assert_eq!( rebuilt_chat["choices"][0]["message"]["refusal"], "partial refusal" ); assert_eq!(rebuilt_chat["service_tier"], "flex"); let rebuilt = canonical_to_openai_responses_response(&canonical, &json!({})); assert_eq!(rebuilt["id"], "resp_123"); assert_eq!(rebuilt["output"][0]["type"], "reasoning"); assert_eq!(rebuilt["output"][1]["content"][0]["text"], "done"); assert_eq!(rebuilt["output"][1]["content"][1]["type"], "refusal"); assert_eq!(rebuilt["output"][2]["type"], "function_call"); assert_eq!(rebuilt["output"][3]["type"], "function_call_output"); assert_eq!(rebuilt["output"][4]["type"], "local_shell_call"); assert_eq!(rebuilt["output"][4]["action"]["command"][0], "pwd"); assert_eq!(rebuilt["output"][5]["type"], "local_shell_call_output"); assert_eq!(rebuilt["output"][5]["call_id"], "call_shell_1"); assert_eq!(rebuilt["usage"]["input_tokens_details"]["cached_tokens"], 2); assert_eq!( rebuilt["usage"]["input_tokens_details"]["cache_write_tokens"], 1 ); assert_eq!( rebuilt["usage"]["output_tokens_details"]["reasoning_tokens"], 1 ); assert_eq!(rebuilt["service_tier"], "flex"); } #[test] fn openai_responses_to_claude_response_drops_empty_pages_only_for_read_tool() { let response = json!({ "id": "resp_read_pages", "object": "response", "status": "completed", "model": "gpt-5.5", "output": [ { "type": "function_call", "id": "call_read", "call_id": "call_read", "name": "Read", "arguments": "{\"file_path\":\"/tmp/a.txt\",\"offset\":0,\"limit\":20,\"pages\":\"\"}" }, { "type": "function_call", "id": "call_search", "call_id": "call_search", "name": "Search", "arguments": "{\"query\":\"\",\"pages\":\"\"}" } ], "usage": { "input_tokens": 1, "output_tokens": 1, "total_tokens": 2 } }); let canonical = from_openai_responses_to_canonical_response(&response).expect("canonical response"); let claude = canonical_to_claude_response(&canonical); assert_eq!( claude["content"][0]["input"], json!({ "file_path": "/tmp/a.txt", "offset": 0, "limit": 20, }) ); assert_eq!( claude["content"][1]["input"], json!({ "query": "", "pages": "", }) ); let rebuilt_responses = canonical_to_openai_responses_response(&canonical, &json!({})); let read_arguments = serde_json::from_str::( rebuilt_responses["output"][0]["arguments"] .as_str() .expect("arguments should be a string"), ) .expect("arguments should be json"); assert_eq!( read_arguments, json!({ "file_path": "/tmp/a.txt", "offset": 0, "limit": 20, "pages": "", }) ); } #[test] fn openai_responses_image_generation_call_becomes_canonical_image_block() { let response = json!({ "id": "resp_img", "model": "gpt-image-2", "status": "completed", "output": [{ "id": "ig_1", "type": "image_generation_call", "status": "completed", "output_format": "png", "result": "aW1hZ2U=" }] }); let canonical = from_openai_responses_to_canonical_response(&response).expect("canonical response"); assert!(matches!( canonical.content[0], CanonicalContentBlock::Image { ref data, ref media_type, .. } if data.as_deref() == Some("aW1hZ2U=") && media_type.as_deref() == Some("image/png") )); let rebuilt_chat = canonical_to_openai_chat_response(&canonical); assert_eq!( rebuilt_chat["choices"][0]["message"]["content"][0]["type"], json!("image_url") ); assert_eq!( rebuilt_chat["choices"][0]["message"]["content"][0]["image_url"]["url"], json!("data:image/png;base64,aW1hZ2U=") ); let rebuilt_responses = canonical_to_openai_responses_response(&canonical, &json!({})); assert_eq!( rebuilt_responses["output"][0]["type"], json!("image_generation_call") ); assert_eq!(rebuilt_responses["output"][0]["result"], json!("aW1hZ2U=")); } #[test] fn claude_request_adapter_preserves_cache_thinking_tools_and_builtin_extensions() { let request = json!({ "model": "claude-sonnet-4-5", "system": [ { "type": "text", "text": "Cache this.", "cache_control": {"type": "ephemeral"} }, {"type": "text", "text": "Be exact."} ], "messages": [ { "role": "assistant", "content": [ { "type": "thinking", "thinking": "plan", "signature": "sig_123" }, { "type": "tool_use", "name": "lookup", "input": {"q": "rust"} } ] }, { "role": "user", "content": [{ "type": "tool_result", "tool_use_id": "toolu_auto_0", "content": {"ok": true} }] } ], "tools": [ { "name": "lookup", "description": "Lookup", "input_schema": {"type": "object"} }, { "type": "web_search_20250305", "name": "web_search", "max_uses": 5 } ], "tool_choice": { "type": "auto", "disable_parallel_tool_use": false }, "thinking": {"type": "enabled", "budget_tokens": 2048}, "output_config": {"effort": "medium"} }); let canonical = from_claude_to_canonical_request(&request).expect("canonical request"); assert_eq!(canonical.instructions.len(), 2); assert_eq!( canonical.instructions[0] .extensions .get("claude") .and_then(|value| value.get("cache_control")) .and_then(|value| value.get("type")) .and_then(Value::as_str), Some("ephemeral") ); assert!(matches!( canonical.messages[0].content[0], CanonicalContentBlock::Thinking { ref text, ref signature, .. } if text == "plan" && signature.as_deref() == Some("sig_123") )); assert!(matches!( canonical.messages[0].content[1], CanonicalContentBlock::ToolUse { ref id, .. } if id == "toolu_auto_0" )); let openai_chat = canonical_to_openai_chat_request(&canonical).expect("openai chat request"); assert_eq!( openai_chat["messages"][2]["reasoning_parts"][0]["signature"], "sig_123" ); assert_eq!( openai_chat["web_search_options"]["search_context_size"], "medium" ); let rebuilt = canonical_to_claude_request(&canonical, "claude-upstream", false).expect("claude"); assert_eq!(rebuilt["model"], "claude-upstream"); assert_eq!(rebuilt["system"][0]["cache_control"]["type"], "ephemeral"); assert_eq!(rebuilt["messages"][1]["content"][0]["signature"], "sig_123"); assert_eq!(rebuilt["tools"][1]["type"], "web_search_20250305"); assert_eq!(rebuilt["thinking"]["budget_tokens"], 2048); assert_eq!(rebuilt["output_config"]["effort"], "medium"); } #[test] fn claude_request_adapter_preserves_official_raw_content_blocks() { let request = json!({ "model": "claude-sonnet-4-5", "messages": [{ "role": "assistant", "content": [ { "type": "server_tool_use", "id": "srvu_1", "name": "web_search", "input": {"query": "rust"} }, { "type": "web_search_tool_result", "tool_use_id": "srvu_1", "content": [{ "type": "web_search_result", "title": "Rust", "url": "https://www.rust-lang.org/", "encrypted_content": "enc" }] } ] }] }); let canonical = from_claude_to_canonical_request(&request).expect("canonical request"); assert_eq!(canonical_request_unknown_block_count(&canonical), 2); let rebuilt = canonical_to_claude_request(&canonical, "claude-upstream", false) .expect("claude request"); assert_eq!( rebuilt["messages"][1]["content"][0]["type"], "server_tool_use" ); assert_eq!( rebuilt["messages"][1]["content"][1]["type"], "web_search_tool_result" ); assert_eq!( rebuilt["messages"][1]["content"][1]["content"][0]["encrypted_content"], "enc" ); } #[test] fn canonical_to_openai_chat_request_rejects_unrepresentable_claude_tool_result() { let request = json!({ "model": "claude-sonnet", "messages": [{ "role": "user", "content": [{ "type": "tool_result", "tool_use_id": "toolu_read", "content": [{ "type": "image", "source": { "type": "unsupported", "media_type": "image/png", "data": "AAAA" } }] }] }] }); let canonical = from_claude_to_canonical_request(&request).expect("canonical request"); assert!(canonical_to_openai_chat_request(&canonical).is_none()); } #[test] fn claude_response_adapter_preserves_thinking_signature_tool_and_cache_usage() { let response = json!({ "id": "msg_123", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5", "content": [ { "type": "thinking", "thinking": "plan", "signature": "sig_123" }, {"type": "text", "text": "done"}, { "type": "tool_use", "id": "toolu_123", "name": "lookup", "input": {"q": "rust"} } ], "stop_reason": "tool_use", "usage": { "input_tokens": 11, "output_tokens": 7, "cache_read_input_tokens": 3, "cache_creation_input_tokens": 2, "output_tokens_details": {"thinking_tokens": 4} } }); let canonical = from_claude_to_canonical_response(&response).expect("canonical response"); assert!(matches!( canonical.content[0], CanonicalContentBlock::Thinking { ref text, ref signature, .. } if text == "plan" && signature.as_deref() == Some("sig_123") )); assert!(matches!( canonical.content[2], CanonicalContentBlock::ToolUse { ref id, ref name, .. } if id == "toolu_123" && name == "lookup" )); assert_eq!(canonical.usage.as_ref().unwrap().cache_read_tokens, 3); assert_eq!(canonical.usage.as_ref().unwrap().cache_write_tokens, 2); assert_eq!(canonical.usage.as_ref().unwrap().reasoning_tokens, 4); let rebuilt = canonical_to_claude_response(&canonical); assert_eq!(rebuilt["content"][0]["signature"], "sig_123"); assert_eq!(rebuilt["content"][2]["name"], "lookup"); assert_eq!(rebuilt["stop_reason"], "tool_use"); assert_eq!(rebuilt["usage"]["cache_read_input_tokens"], 3); assert_eq!(rebuilt["usage"]["cache_creation_input_tokens"], 2); assert_eq!( rebuilt["usage"]["output_tokens_details"]["thinking_tokens"], 4 ); let rebuilt_chat = canonical_to_openai_chat_response(&canonical); assert_eq!( rebuilt_chat["usage"]["completion_tokens_details"]["reasoning_tokens"], 4 ); let rebuilt_openai = canonical_to_openai_responses_response(&canonical, &json!({})); assert_eq!( rebuilt_openai["usage"]["output_tokens_details"]["reasoning_tokens"], 4 ); assert_eq!(rebuilt_openai["usage"]["input_tokens"], 16); assert_eq!( rebuilt_openai["usage"]["input_tokens_details"]["cached_tokens"], 3 ); assert_eq!( rebuilt_openai["usage"]["input_tokens_details"]["cache_write_tokens"], 2 ); assert_eq!(rebuilt_openai["usage"]["total_tokens"], 23); let rebuilt_gemini = canonical_to_gemini_response(&canonical, &json!({})).expect("gemini"); assert_eq!(rebuilt_gemini["usageMetadata"]["promptTokenCount"], 16); assert_eq!( rebuilt_gemini["usageMetadata"]["cachedContentTokenCount"], 3 ); assert_eq!(rebuilt_gemini["usageMetadata"]["totalTokenCount"], 23); } #[test] fn openai_usage_prefers_cache_write_tokens_and_emits_the_official_field() { let response = json!({ "id": "resp_cache_write", "model": "gpt-5.6-sol", "status": "completed", "output": [], "usage": { "input_tokens": 20, "output_tokens": 4, "total_tokens": 24, "input_tokens_details": { "cached_tokens": 3, "cache_write_tokens": 7, "cached_creation_tokens": 99 } } }); let canonical = from_openai_responses_to_canonical_response(&response) .expect("Responses usage should parse"); assert_eq!(canonical.usage.as_ref().unwrap().cache_write_tokens, 7); let rebuilt = canonical_to_openai_responses_response(&canonical, &json!({})); assert_eq!( rebuilt["usage"]["input_tokens_details"]["cache_write_tokens"], 7 ); assert!(rebuilt["usage"]["input_tokens_details"] .get("cached_creation_tokens") .is_none()); let rebuilt_chat = canonical_to_openai_chat_response(&canonical); assert_eq!( rebuilt_chat["usage"]["prompt_tokens_details"]["cache_write_tokens"], 7 ); assert!(rebuilt_chat["usage"]["prompt_tokens_details"] .get("cached_creation_tokens") .is_none()); } #[test] fn openai_usage_accepts_cached_creation_tokens_as_legacy_input_alias() { let response = json!({ "id": "resp_cache_write_legacy", "model": "gpt-5.6-sol", "status": "completed", "output": [], "usage": { "input_tokens": 10, "output_tokens": 2, "input_tokens_details": {"cached_creation_tokens": 5} } }); let canonical = from_openai_responses_to_canonical_response(&response) .expect("legacy usage alias should parse"); assert_eq!(canonical.usage.as_ref().unwrap().cache_write_tokens, 5); } #[test] fn gemini_request_pairs_parallel_idless_function_responses_by_order() { let contents = json!([ { "role": "model", "parts": [ {"functionCall": {"name": "lookup", "args": {"q": "first"}}}, {"functionCall": {"name": "lookup", "args": {"q": "second"}}} ] }, { "role": "user", "parts": [ {"functionResponse": {"name": "lookup", "response": {"result": "one"}}}, {"functionResponse": {"name": "lookup", "response": {"result": "two"}}} ] } ]); let messages = super::gemini_contents_to_canonical_messages(Some(&contents)) .expect("Gemini contents should parse"); let call_ids = messages[0] .content .iter() .map(|block| match block { CanonicalContentBlock::ToolUse { id, .. } => id.as_str(), _ => panic!("expected tool use"), }) .collect::>(); let result_ids = messages[1] .content .iter() .map(|block| match block { CanonicalContentBlock::ToolResult { tool_use_id, .. } => tool_use_id.as_str(), _ => panic!("expected tool result"), }) .collect::>(); assert_ne!(call_ids[0], call_ids[1]); assert_eq!(result_ids, call_ids); } #[test] fn gemini_request_pairs_idless_function_responses_by_name() { let contents = json!([{ "role": "model", "parts": [ {"functionCall": {"name": "first", "args": {}}}, {"functionCall": {"name": "second", "args": {}}} ] }, { "role": "user", "parts": [ {"functionResponse": {"name": "second", "response": {"result": 2}}}, {"functionResponse": {"name": "first", "response": {"result": 1}}} ] }]); let messages = super::gemini_contents_to_canonical_messages(Some(&contents)) .expect("Gemini contents should parse"); let call_ids = messages[0] .content .iter() .map(|block| match block { CanonicalContentBlock::ToolUse { id, .. } => id.as_str(), _ => panic!("expected tool use"), }) .collect::>(); let result_ids = messages[1] .content .iter() .map(|block| match block { CanonicalContentBlock::ToolResult { tool_use_id, .. } => tool_use_id.as_str(), _ => panic!("expected tool result"), }) .collect::>(); assert_eq!(result_ids, vec![call_ids[1], call_ids[0]]); } #[test] fn gemini_request_generated_function_call_ids_avoid_explicit_ids() { let contents = json!([{ "role": "model", "parts": [ {"functionCall": {"name": "first", "args": {}}}, {"functionCall": {"id": "call_auto_0", "name": "second", "args": {}}} ] }]); let messages = super::gemini_contents_to_canonical_messages(Some(&contents)) .expect("Gemini contents should parse"); let call_ids = messages[0] .content .iter() .map(|block| match block { CanonicalContentBlock::ToolUse { id, .. } => id.as_str(), _ => panic!("expected tool use"), }) .collect::>(); assert_eq!(call_ids, vec!["call_auto_1", "call_auto_0"]); } #[test] fn gemini_request_generated_function_call_ids_avoid_explicit_response_ids() { let contents = json!([{ "role": "model", "parts": [{"functionCall": {"name": "generated", "args": {}}}] }, { "role": "user", "parts": [{ "functionResponse": { "id": "call_auto_0", "name": "external", "response": {"result": "done"} } }] }]); let messages = super::gemini_contents_to_canonical_messages(Some(&contents)) .expect("Gemini contents should parse"); let CanonicalContentBlock::ToolUse { id: call_id, .. } = &messages[0].content[0] else { panic!("expected tool use"); }; let CanonicalContentBlock::ToolResult { tool_use_id, .. } = &messages[1].content[0] else { panic!("expected tool result"); }; assert_eq!(call_id, "call_auto_1"); assert_eq!(tool_use_id, "call_auto_0"); } #[test] fn gemini_request_adapter_preserves_thinking_tools_media_and_extensions() { let request = json!({ "systemInstruction": { "parts": [ {"text": "Be exact.", "cacheControl": {"type": "ephemeral"}} ] }, "contents": [ { "role": "user", "parts": [ {"text": "Inspect this"}, {"inlineData": {"mimeType": "image/png", "data": "iVBORw0KGgo="}}, {"fileData": {"fileUri": "https://example.com/spec.pdf", "mimeType": "application/pdf"}} ] }, { "role": "model", "parts": [ {"text": "plan", "thought": true, "thoughtSignature": "sig_123"}, {"functionCall": {"id": "call_123", "name": "lookup", "args": {"q": "rust"}}} ] }, { "role": "user", "parts": [{ "functionResponse": { "id": "call_123", "name": "lookup", "response": {"result": {"ok": true}} } }] } ], "generationConfig": { "maxOutputTokens": 64, "temperature": 0.2, "topP": 0.9, "topK": 40, "candidateCount": 2, "seed": 7, "stopSequences": ["END"], "thinkingConfig": {"includeThoughts": true, "thinkingBudget": 2048}, "responseMimeType": "application/json", "responseSchema": {"type": "object"}, "responseModalities": ["TEXT"], "routingConfig": {"autoMode": {}} }, "tools": [ {"googleSearch": {}}, {"codeExecution": {}}, { "functionDeclarations": [{ "name": "lookup", "description": "Lookup data", "parameters": {"type": "object"} }] } ], "toolConfig": { "functionCallingConfig": { "mode": "ANY", "allowedFunctionNames": ["lookup"] } }, "safetySettings": [{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"}], "cachedContent": "cached/abc" }); let canonical = from_gemini_to_canonical_request( &request, "/v1beta/models/gemini-2.5-pro:generateContent", ) .expect("canonical request"); assert_eq!(canonical.model, "gemini-2.5-pro"); assert_eq!(canonical.instructions[0].text, "Be exact."); assert_eq!(canonical.generation.max_tokens, Some(64)); assert_eq!(canonical.generation.top_k, Some(40)); assert_eq!(canonical.generation.n, Some(2)); assert_eq!(canonical.tools[0].name, "lookup"); assert!(matches!( canonical.messages[0].content[1], CanonicalContentBlock::Image { ref data, ref media_type, .. } if data.as_deref() == Some("iVBORw0KGgo=") && media_type.as_deref() == Some("image/png") )); assert!(matches!( canonical.messages[1].content[0], CanonicalContentBlock::Thinking { ref text, ref signature, .. } if text == "plan" && signature.as_deref() == Some("sig_123") )); assert_eq!( canonical .extensions .get("gemini") .and_then(|value| value.get("cached_content")) .and_then(Value::as_str), Some("cached/abc") ); assert_eq!( canonical .extensions .get("openai") .and_then(|value| value.get("web_search_options")), Some(&json!({})) ); let rebuilt = canonical_to_gemini_request(&canonical, "gemini-upstream", false).expect("gemini"); assert_eq!(rebuilt["model"], "gemini-upstream"); assert_eq!( rebuilt["generationConfig"]["thinkingConfig"]["thinkingBudget"], 2048 ); assert_eq!( rebuilt["generationConfig"]["responseModalities"], json!(["TEXT"]) ); assert_eq!( rebuilt["generationConfig"]["routingConfig"]["autoMode"], json!({}) ); assert_eq!(rebuilt["safetySettings"], request["safetySettings"]); assert_eq!(rebuilt["cachedContent"], "cached/abc"); assert_eq!(rebuilt["tools"], request["tools"]); assert_eq!(rebuilt["toolConfig"], request["toolConfig"]); } #[test] fn gemini_request_adapter_normalizes_google_search_grounding_aliases() { let cases = [ ( "current_camel", json!({"googleSearch": {"excludeDomains": ["example.com"]}}), "googleSearch", false, json!({"excludeDomains": ["example.com"]}), json!({"excludeDomains": ["example.com"]}), ), ( "current_snake", json!({"google_search": {"exclude_domains": ["example.com"]}}), "google_search", false, json!({"excludeDomains": ["example.com"]}), json!({"excludeDomains": ["example.com"]}), ), ( "legacy_snake", json!({ "google_search_retrieval": { "dynamic_retrieval_config": { "mode": "MODE_DYNAMIC", "dynamic_threshold": 0.7 } } }), "google_search_retrieval", true, json!({ "dynamicRetrievalConfig": { "mode": "MODE_DYNAMIC", "dynamicThreshold": 0.7 } }), json!({}), ), ( "legacy_camel", json!({ "googleSearchRetrieval": { "dynamicRetrievalConfig": { "mode": "MODE_DYNAMIC", "dynamicThreshold": 0.7 } } }), "googleSearchRetrieval", true, json!({ "dynamicRetrievalConfig": { "mode": "MODE_DYNAMIC", "dynamicThreshold": 0.7 } }), json!({}), ), ]; for ( name, tool, source_field, legacy, expected_extension_payload, expected_output_payload, ) in cases { let request = json!({ "model": "gemini-2.5-pro", "contents": [{"role": "user", "parts": [{"text": "search"}]}], "tools": [tool] }); let canonical = from_gemini_to_canonical_request( &request, "/v1beta/models/gemini-2.5-pro:generateContent", ) .unwrap_or_else(|| panic!("{name}: canonical request")); assert_eq!( canonical .extensions .get("openai") .and_then(|value| value.get("web_search_options")), Some(&json!({})), "{name}: web search option" ); let google_search = canonical .extensions .get("gemini") .and_then(|value| value.get("grounding")) .and_then(|value| value.get("google_search")) .unwrap_or_else(|| panic!("{name}: gemini google_search grounding")); assert_eq!( google_search.get("source_field").and_then(Value::as_str), Some(source_field), "{name}: source field" ); assert_eq!( google_search.get("legacy").and_then(Value::as_bool), Some(legacy), "{name}: legacy flag" ); assert_eq!( google_search.get("payload"), Some(&expected_extension_payload), "{name}: normalized payload" ); let rebuilt = canonical_to_gemini_request(&canonical, "gemini-upstream", false).unwrap(); assert_eq!( rebuilt["tools"], json!([{"googleSearch": expected_output_payload}]), "{name}: canonical output" ); } } #[test] fn gemini_request_adapter_keeps_agent_search_retrieval_separate_from_google_search() { let request = json!({ "model": "gemini-2.5-pro", "contents": [{"role": "user", "parts": [{"text": "private data"}]}], "tools": [{ "retrieval": { "vertexAiSearch": { "datastore": "projects/p/locations/global/collections/default_collection/dataStores/d" } } }] }); let canonical = from_gemini_to_canonical_request( &request, "/v1beta/models/gemini-2.5-pro:generateContent", ) .expect("canonical request"); assert_eq!( canonical .extensions .get("openai") .and_then(|value| value.get("web_search_options")), None ); let rebuilt = canonical_to_gemini_request(&canonical, "gemini-upstream", false).unwrap(); assert_eq!(rebuilt["tools"], request["tools"]); assert!(rebuilt["tools"] .as_array() .unwrap() .iter() .all(|tool| tool.get("googleSearch").is_none())); } #[test] fn gemini_request_adapter_preserves_combined_search_builtin_tool_fields() { let cases = [ ( "current_snake", json!({ "google_search": {}, "code_execution": {}, "url_context": {}, "retrieval": { "vertexAiSearch": { "datastore": "projects/p/locations/global/collections/default_collection/dataStores/d" } } }), ), ( "legacy_snake", json!({ "google_search_retrieval": { "dynamic_retrieval_config": { "mode": "MODE_DYNAMIC", "dynamic_threshold": 0.7 } }, "code_execution": {}, "url_context": {} }), ), ]; for (name, tool) in cases { let request = json!({ "model": "gemini-2.5-pro", "contents": [{"role": "user", "parts": [{"text": "search with builtins"}]}], "tools": [tool] }); let canonical = from_gemini_to_canonical_request( &request, "/v1beta/models/gemini-2.5-pro:generateContent", ) .unwrap_or_else(|| panic!("{name}: canonical request")); let rebuilt = canonical_to_gemini_request(&canonical, "gemini-upstream", false).unwrap(); let tools = rebuilt["tools"] .as_array() .unwrap_or_else(|| panic!("{name}: tools array")); assert!( tools.iter().any(|tool| tool.get("googleSearch").is_some()), "{name}: google search should be preserved" ); assert!( tools.iter().any(|tool| tool.get("codeExecution").is_some()), "{name}: code execution should be preserved" ); assert!( tools.iter().any(|tool| tool.get("urlContext").is_some()), "{name}: URL context should be preserved" ); if name == "current_snake" { assert!( tools.iter().any(|tool| tool.get("retrieval").is_some()), "{name}: unhandled retrieval should be preserved" ); } } } #[test] fn gemini_response_adapter_preserves_thought_signature_tool_and_usage() { let response = json!({ "responseId": "resp_123", "modelVersion": "gemini-2.5-pro", "candidates": [{ "index": 0, "finishReason": "STOP", "content": { "parts": [ {"text": "plan", "thought": true, "thoughtSignature": "sig_123"}, {"text": "done"}, {"functionCall": {"id": "call_123", "name": "lookup", "args": {"q": "rust"}}}, { "functionResponse": { "id": "call_123", "name": "lookup", "response": {"result": {"ok": true}} } } ] } }], "usageMetadata": { "promptTokenCount": 10, "cachedContentTokenCount": 4, "candidatesTokenCount": 5, "thoughtsTokenCount": 2, "totalTokenCount": 17 } }); let canonical = from_gemini_to_canonical_response(&response).expect("canonical response"); assert_eq!(canonical.id, "resp_123"); assert_eq!(canonical.model, "gemini-2.5-pro"); assert!(matches!( canonical.content[0], CanonicalContentBlock::Thinking { ref text, ref signature, .. } if text == "plan" && signature.as_deref() == Some("sig_123") )); assert!(canonical.content.iter().any( |block| matches!(block, CanonicalContentBlock::ToolUse { name, .. } if name == "lookup") )); assert!(canonical.content.iter().any( |block| matches!(block, CanonicalContentBlock::ToolResult { tool_use_id, name: Some(name), output: Some(output), .. } if tool_use_id == "call_123" && name == "lookup" && output == &json!({"ok": true})) )); assert_eq!(canonical.usage.as_ref().unwrap().input_tokens, 10); assert!(canonical.usage.as_ref().unwrap().input_tokens_include_cache); assert_eq!(canonical.usage.as_ref().unwrap().cache_read_tokens, 4); assert_eq!(canonical.usage.as_ref().unwrap().output_tokens, 7); assert_eq!(canonical.usage.as_ref().unwrap().reasoning_tokens, 2); let claude = canonical_to_claude_response(&canonical); assert_eq!(claude["usage"]["input_tokens"], 6); assert_eq!(claude["usage"]["cache_read_input_tokens"], 4); let rebuilt = canonical_to_gemini_response(&canonical, &json!({})).expect("gemini"); assert_eq!( rebuilt["candidates"][0]["content"]["parts"][0]["thoughtSignature"], "sig_123" ); assert_eq!( rebuilt["candidates"][0]["content"]["parts"][2]["functionCall"]["name"], "lookup" ); assert_eq!( rebuilt["candidates"][0]["content"]["parts"][3]["functionResponse"]["response"], json!({"ok": true}) ); assert_eq!(rebuilt["usageMetadata"]["promptTokenCount"], 10); assert_eq!(rebuilt["usageMetadata"]["cachedContentTokenCount"], 4); assert_eq!(rebuilt["usageMetadata"]["totalTokenCount"], 17); assert_eq!(rebuilt["usageMetadata"]["thoughtsTokenCount"], 2); } #[test] fn gemini_response_adapter_preserves_grounding_metadata() { let grounding_metadata = json!({ "webSearchQueries": ["query"], "searchEntryPoint": {"renderedContent": ""}, "groundingChunks": [{ "web": { "uri": "https://example.com", "title": "Example" } }], "groundingSupports": [] }); let response = json!({ "responseId": "resp_grounded", "modelVersion": "gemini-2.5-pro", "candidates": [{ "index": 0, "finishReason": "STOP", "groundingMetadata": grounding_metadata, "content": { "parts": [{"text": "grounded answer"}] } }] }); let canonical = from_gemini_to_canonical_response(&response).expect("canonical response"); assert_eq!( canonical.outputs[0] .extensions .get("gemini") .and_then(|value| value.get("groundingMetadata")), Some(&grounding_metadata) ); let rebuilt = canonical_to_gemini_response(&canonical, &json!({})).expect("gemini"); assert_eq!( rebuilt["candidates"][0]["groundingMetadata"], grounding_metadata ); } #[test] fn canonical_response_preserves_openai_choices_and_gemini_candidates() { let openai_response = json!({ "id": "chatcmpl_multi", "object": "chat.completion", "model": "gpt-5", "choices": [ { "index": 0, "message": {"role": "assistant", "content": "first"}, "finish_reason": "stop" }, { "index": 1, "message": {"role": "assistant", "content": "second"}, "finish_reason": "length" } ], "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3} }); let canonical = from_openai_chat_to_canonical_response(&openai_response).expect("canonical"); assert_eq!(canonical.outputs.len(), 2); let rebuilt = canonical_to_openai_chat_response(&canonical); assert_eq!(rebuilt["choices"][0]["message"]["content"], "first"); assert_eq!(rebuilt["choices"][1]["message"]["content"], "second"); assert_eq!(rebuilt["choices"][1]["finish_reason"], "length"); let gemini_response = json!({ "responseId": "gemini_multi", "modelVersion": "gemini-2.5-pro", "candidates": [ { "index": 0, "finishReason": "STOP", "content": {"role": "model", "parts": [{"text": "first"}]} }, { "index": 1, "finishReason": "MAX_TOKENS", "content": {"role": "model", "parts": [{"text": "second"}]} } ], "usageMetadata": { "promptTokenCount": 1, "candidatesTokenCount": 2, "totalTokenCount": 3 } }); let canonical = from_gemini_to_canonical_response(&gemini_response).expect("canonical"); assert_eq!(canonical.outputs.len(), 2); let rebuilt = canonical_to_openai_chat_response(&canonical); assert_eq!(rebuilt["choices"][0]["message"]["content"], "first"); assert_eq!(rebuilt["choices"][1]["message"]["content"], "second"); assert_eq!(rebuilt["choices"][1]["finish_reason"], "length"); } }