mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-03 16:07:46 +08:00
Raw chain-of-thought was written to both `content` (`reasoning_text`) and `summary` (`summary_text`), and the stream emitter sent the same delta on `response.reasoning_text.delta` *and* `response.reasoning_summary_text.delta`. Clients that render both channels therefore printed every thinking chunk twice — most visibly the Codex CLI, whose thinking panel repeated itself. OpenAI keeps the two channels distinct: `content` carries the raw CoT while `summary` is the summarised view. Emit the thinking on `content` only: - `openai_responses_reasoning_text_fields` becomes `openai_responses_reasoning_text_parts`, returning just the `content` array; reasoning items keep `summary: []` (or a provider-supplied summary). - The Responses stream emitter emits `response.reasoning_text.delta` / `.done` and no longer mirrors them onto the summary events. The reasoning `output_item.added` no longer announces a `reasoning_summary_part`. - The provider-state reasoning reader accepts `content` (`reasoning_text`) first and falls back to `summary`, so it also understands items produced by older Aether versions; its state field is renamed accordingly. - Non-streaming builders (Chat -> Responses, manual Responses response, Grok gateway) place the thinking on `content` and leave `summary` empty. Tests cover the raw thinking appearing exactly once in the emitted stream.
9774 lines
352 KiB
Rust
9774 lines
352 KiB
Rust
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<String, Value>,
|
|
},
|
|
Thinking {
|
|
text: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
signature: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
encrypted_content: Option<String>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
extensions: BTreeMap<String, Value>,
|
|
},
|
|
Image {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
data: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
url: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
media_type: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
detail: Option<String>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
extensions: BTreeMap<String, Value>,
|
|
},
|
|
File {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
data: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
file_id: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
file_url: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
media_type: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
filename: Option<String>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
extensions: BTreeMap<String, Value>,
|
|
},
|
|
Audio {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
data: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
media_type: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
format: Option<String>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
extensions: BTreeMap<String, Value>,
|
|
},
|
|
ToolUse {
|
|
id: String,
|
|
name: String,
|
|
#[serde(default)]
|
|
input: Value,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
extensions: BTreeMap<String, Value>,
|
|
},
|
|
ToolResult {
|
|
tool_use_id: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
name: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
output: Option<Value>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
content_text: Option<String>,
|
|
#[serde(default)]
|
|
is_error: bool,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
extensions: BTreeMap<String, Value>,
|
|
},
|
|
Unknown {
|
|
raw_type: String,
|
|
payload: Value,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
extensions: BTreeMap<String, Value>,
|
|
},
|
|
}
|
|
|
|
#[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<String, Value>,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct CanonicalMessage {
|
|
pub role: CanonicalRole,
|
|
#[serde(default)]
|
|
pub content: Vec<CanonicalContentBlock>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub extensions: BTreeMap<String, Value>,
|
|
}
|
|
|
|
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct CanonicalGenerationConfig {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub max_tokens: Option<u64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub temperature: Option<f64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub top_p: Option<f64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub top_k: Option<u64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub stop_sequences: Option<Vec<String>>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub n: Option<u64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub presence_penalty: Option<f64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub frequency_penalty: Option<f64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub seed: Option<i64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub logprobs: Option<bool>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub top_logprobs: Option<u64>,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct CanonicalToolDefinition {
|
|
pub name: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub parameters: Option<Value>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub strict: Option<bool>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub extensions: BTreeMap<String, Value>,
|
|
}
|
|
|
|
#[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<u64>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub extensions: BTreeMap<String, Value>,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct CanonicalResponseFormat {
|
|
pub format_type: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub json_schema: Option<Value>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub extensions: BTreeMap<String, Value>,
|
|
}
|
|
|
|
#[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<String, Value>,
|
|
}
|
|
|
|
fn is_false(value: &bool) -> bool {
|
|
!*value
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(untagged)]
|
|
pub enum CanonicalEmbeddingInput {
|
|
String(String),
|
|
StringArray(Vec<String>),
|
|
TokenArray(Vec<i64>),
|
|
TokenArrayArray(Vec<Vec<i64>>),
|
|
Multimodal(Vec<CanonicalEmbeddingContent>),
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct CanonicalEmbeddingContent {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub text: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub image: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub video: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub multi_images: Option<Vec<String>>,
|
|
}
|
|
|
|
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<Vec<&str>> {
|
|
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<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub dimensions: Option<u64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub task: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub user: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub parameters: Option<Map<String, Value>>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub extensions: BTreeMap<String, Value>,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct CanonicalRerankRequest {
|
|
pub query: String,
|
|
#[serde(default)]
|
|
pub documents: Vec<Value>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub top_n: Option<u64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub return_documents: Option<bool>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub extensions: BTreeMap<String, Value>,
|
|
}
|
|
|
|
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<f64>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub extensions: BTreeMap<String, Value>,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct CanonicalEmbeddingResponse {
|
|
pub id: String,
|
|
pub model: String,
|
|
#[serde(default)]
|
|
pub embeddings: Vec<CanonicalEmbedding>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub usage: Option<CanonicalUsage>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub extensions: BTreeMap<String, Value>,
|
|
}
|
|
|
|
#[derive(Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct CanonicalRequest {
|
|
#[serde(default)]
|
|
pub model: String,
|
|
#[serde(default)]
|
|
pub instructions: Vec<CanonicalInstruction>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub system: Option<String>,
|
|
#[serde(default)]
|
|
pub messages: Vec<CanonicalMessage>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub embedding: Option<CanonicalEmbeddingRequest>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub rerank: Option<CanonicalRerankRequest>,
|
|
#[serde(default)]
|
|
pub generation: CanonicalGenerationConfig,
|
|
#[serde(default)]
|
|
pub tools: Vec<CanonicalToolDefinition>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub tool_choice: Option<CanonicalToolChoice>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub thinking: Option<CanonicalThinkingConfig>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub response_format: Option<CanonicalResponseFormat>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub parallel_tool_calls: Option<bool>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub metadata: Option<Value>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub extensions: BTreeMap<String, Value>,
|
|
}
|
|
|
|
#[derive(Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct CanonicalResponseOutput {
|
|
#[serde(default)]
|
|
pub index: usize,
|
|
#[serde(default)]
|
|
pub role: CanonicalRole,
|
|
#[serde(default)]
|
|
pub content: Vec<CanonicalContentBlock>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub stop_reason: Option<CanonicalStopReason>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub extensions: BTreeMap<String, Value>,
|
|
}
|
|
|
|
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<CanonicalResponseOutput>,
|
|
#[serde(default)]
|
|
pub content: Vec<CanonicalContentBlock>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub stop_reason: Option<CanonicalStopReason>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub usage: Option<CanonicalUsage>,
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub extensions: BTreeMap<String, Value>,
|
|
}
|
|
|
|
fn debug_json_bytes(value: &Value) -> Option<usize> {
|
|
serde_json::to_vec(value).ok().map(|bytes| bytes.len())
|
|
}
|
|
|
|
fn debug_json_map_bytes(value: &Map<String, Value>) -> Option<usize> {
|
|
serde_json::to_vec(value).ok().map(|bytes| bytes.len())
|
|
}
|
|
|
|
fn debug_json_option_bytes(value: Option<&Value>) -> Option<usize> {
|
|
value.and_then(debug_json_bytes)
|
|
}
|
|
|
|
fn debug_string_len(value: Option<&str>) -> Option<usize> {
|
|
value.map(str::len)
|
|
}
|
|
|
|
fn debug_string_list_summary(value: Option<&Vec<String>>) -> Option<(usize, usize)> {
|
|
value.map(|values| (values.len(), values.iter().map(String::len).sum::<usize>()))
|
|
}
|
|
|
|
fn debug_json_list_summary(value: &[Value]) -> (usize, usize) {
|
|
(
|
|
value.len(),
|
|
value.iter().filter_map(debug_json_bytes).sum::<usize>(),
|
|
)
|
|
}
|
|
|
|
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::<usize>(),
|
|
),
|
|
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::<usize>(),
|
|
),
|
|
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::<usize>())),
|
|
)
|
|
.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<CanonicalRequest> {
|
|
crate::formats::openai::chat::request::from_raw(body_json)
|
|
}
|
|
|
|
pub fn canonical_to_openai_chat_request(canonical: &CanonicalRequest) -> Option<Value> {
|
|
crate::formats::openai::chat::request::to(
|
|
canonical,
|
|
&crate::formats::context::FormatContext::default(),
|
|
)
|
|
}
|
|
|
|
pub fn from_openai_responses_to_canonical_request(body_json: &Value) -> Option<CanonicalRequest> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<Value> {
|
|
canonical_to_openai_responses_request_with_profile(canonical, mapped_model, false, true)
|
|
}
|
|
|
|
pub fn from_claude_to_canonical_request(body_json: &Value) -> Option<CanonicalRequest> {
|
|
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<Value> {
|
|
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<CanonicalRequest> {
|
|
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<Value> {
|
|
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<CanonicalRequest> {
|
|
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<Value> {
|
|
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<CanonicalResponse> {
|
|
crate::formats::openai::chat::response::from_raw(body_json)
|
|
}
|
|
|
|
pub fn from_openai_responses_to_canonical_response(body_json: &Value) -> Option<CanonicalResponse> {
|
|
crate::formats::openai::responses::response::from_raw(body_json)
|
|
}
|
|
|
|
pub fn from_claude_to_canonical_response(body_json: &Value) -> Option<CanonicalResponse> {
|
|
crate::formats::claude::messages::response::from_raw(body_json)
|
|
}
|
|
|
|
pub fn from_gemini_to_canonical_response(body_json: &Value) -> Option<CanonicalResponse> {
|
|
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<String, Value>,
|
|
) -> 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<String, Value>,
|
|
) -> 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<String, Value>,
|
|
) -> 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, Value>,
|
|
) -> 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<String, Value>,
|
|
prefix: &str,
|
|
) -> Option<String> {
|
|
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<String, Value>,
|
|
) -> 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<String, Value>,
|
|
) -> Option<Value> {
|
|
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<Value> {
|
|
crate::formats::gemini::generate_content::response::to_raw(canonical, report_context)
|
|
}
|
|
|
|
pub fn from_embedding_to_canonical_response(
|
|
body_json: &Value,
|
|
namespace: &str,
|
|
) -> Option<CanonicalEmbeddingResponse> {
|
|
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<Value> {
|
|
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<Vec<CanonicalInstruction>> {
|
|
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<Vec<CanonicalMessage>> {
|
|
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::<BTreeSet<_>>();
|
|
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<CanonicalContentBlock> {
|
|
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<String, Value>) -> 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<String, Value>,
|
|
part_object: &Map<String, Value>,
|
|
) -> Option<CanonicalContentBlock> {
|
|
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<String, Value>,
|
|
part_object: &Map<String, Value>,
|
|
) -> Option<CanonicalContentBlock> {
|
|
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, Value>) -> 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<Vec<CanonicalInstruction>> {
|
|
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<String, Value>,
|
|
) -> BTreeMap<String, Value> {
|
|
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<String, Value>) {
|
|
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<String, Value>) -> 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<Vec<CanonicalMessage>> {
|
|
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<Vec<CanonicalContentBlock>> {
|
|
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<CanonicalContentBlock> {
|
|
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<String, Value>,
|
|
image: bool,
|
|
) -> Option<CanonicalContentBlock> {
|
|
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<String, Value>,
|
|
) -> Option<Vec<CanonicalContentBlock>> {
|
|
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<String, Value>,
|
|
) -> 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<String, Value>) -> Vec<CanonicalContentBlock> {
|
|
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<Vec<CanonicalMessage>> {
|
|
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<CanonicalContentBlock> = 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<CanonicalMessage>,
|
|
mut tool_use: CanonicalContentBlock,
|
|
pending_reasoning: &mut Option<CanonicalContentBlock>,
|
|
) {
|
|
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<String, Value>,
|
|
) -> Option<CanonicalContentBlock> {
|
|
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, Value>) -> 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<String> {
|
|
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<String> {
|
|
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<Vec<CanonicalContentBlock>> {
|
|
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<CanonicalContentBlock>, BTreeMap<String, Value>)> {
|
|
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<String, Value>,
|
|
item_type: &str,
|
|
index: usize,
|
|
) -> Option<CanonicalContentBlock> {
|
|
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<String, Value>,
|
|
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<String, Value>, 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<String, Value>,
|
|
) -> Option<CanonicalContentBlock> {
|
|
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<CanonicalContentBlock> {
|
|
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<Vec<CanonicalContentBlock>> {
|
|
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<CanonicalContentBlock> {
|
|
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<Value> {
|
|
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<Value> {
|
|
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<String, Value>) -> BTreeMap<String, Value> {
|
|
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<String, Value>) -> BTreeMap<String, Value> {
|
|
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<String, Value>,
|
|
) -> BTreeMap<String, Value> {
|
|
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<String, Value>,
|
|
) -> BTreeMap<String, Value> {
|
|
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<String, Value>) {
|
|
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<String, Value>) -> BTreeMap<String, Value> {
|
|
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<String, Value>) {
|
|
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<String, Value>,
|
|
item_object: &Map<String, Value>,
|
|
) {
|
|
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<String, Value>) {
|
|
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<String, Value>) {
|
|
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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> 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<String> {
|
|
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<Value> {
|
|
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<String, Value>) -> Option<Value> {
|
|
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<String, Value>) -> Option<Value> {
|
|
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<String>) -> 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::<Vec<_>>()
|
|
.join("\n\n")
|
|
}
|
|
|
|
fn anthropic_source_media_type(source: &Map<String, Value>) -> 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<String, Value>, 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<Value> {
|
|
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<String, Value>,
|
|
) -> Option<Value> {
|
|
[
|
|
"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<String, Value>,
|
|
extensions: &BTreeMap<String, Value>,
|
|
) {
|
|
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<Value> {
|
|
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<Value>,
|
|
message_content: &mut Vec<Value>,
|
|
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<Value>, 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::<Vec<_>>()
|
|
.join(""),
|
|
);
|
|
}
|
|
Value::Array(parts)
|
|
}
|
|
|
|
fn coalesce_openai_responses_text_content(content: Vec<Value>) -> Vec<Value> {
|
|
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::<Vec<_>>()
|
|
.join("\n"),
|
|
_ => String::new(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn claude_generation_config(request: &Map<String, Value>) -> 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<CanonicalToolDefinition>, Vec<Value>, Option<Value>)> {
|
|
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<String, Value>) -> Option<Value> {
|
|
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<CanonicalToolChoice> {
|
|
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<bool> {
|
|
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<String, Value>,
|
|
) -> Option<CanonicalThinkingConfig> {
|
|
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<CanonicalThinkingConfig> {
|
|
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<CanonicalResponseFormat> {
|
|
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<CanonicalToolDefinition>,
|
|
Vec<Value>,
|
|
Option<Value>,
|
|
Option<Value>,
|
|
Option<Value>,
|
|
);
|
|
|
|
#[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<String, Value>,
|
|
) -> Option<GeminiGoogleSearchGrounding> {
|
|
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<String, Value>) -> Option<Value> {
|
|
let builtin = tool_object
|
|
.iter()
|
|
.filter(|(key, _)| {
|
|
key.as_str() != "functionDeclarations" && key.as_str() != "function_declarations"
|
|
})
|
|
.map(|(key, value)| (key.clone(), value.clone()))
|
|
.collect::<Map<_, _>>();
|
|
(!builtin.is_empty()).then_some(Value::Object(builtin))
|
|
}
|
|
|
|
pub(crate) fn gemini_tools_to_canonical(value: Option<&Value>) -> Option<GeminiCanonicalTools> {
|
|
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<CanonicalToolChoice> {
|
|
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<String, Value>) -> 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<String, Value>,
|
|
) -> 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<String, Value>,
|
|
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<Vec<CanonicalToolDefinition>> {
|
|
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<Vec<CanonicalToolDefinition>> {
|
|
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<String, Value>, source: BTreeMap<String, Value>) {
|
|
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<CanonicalToolChoice> {
|
|
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<CanonicalToolChoice> {
|
|
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<String, Value>, tool_type: &str) -> Option<Value> {
|
|
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<String, Value>,
|
|
tool_type: &str,
|
|
) -> Option<Value> {
|
|
let name = raw_tool_choice_name(object, tool_type)?;
|
|
Some(json!({
|
|
"type": tool_type,
|
|
"name": name,
|
|
}))
|
|
}
|
|
|
|
fn raw_tool_choice_name(object: &Map<String, Value>, tool_type: &str) -> Option<String> {
|
|
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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> Map<String, Value> {
|
|
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<Value> {
|
|
let instructions = instructions
|
|
.iter()
|
|
.filter(|instruction| !instruction.text.trim().is_empty())
|
|
.collect::<Vec<_>>();
|
|
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::<Vec<_>>()
|
|
.join("\n\n"),
|
|
))
|
|
}
|
|
}
|
|
|
|
pub(crate) fn canonical_messages_to_claude(canonical: &CanonicalRequest) -> Option<Vec<Value>> {
|
|
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<Vec<Value>> {
|
|
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<Option<Value>> {
|
|
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<String, Value>,
|
|
) -> 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<String, Value>) -> 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<Value> {
|
|
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>) -> 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<Value>) -> Vec<Value> {
|
|
let mut compact: Vec<Value> = 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::<Vec<_>>();
|
|
target_object.insert(
|
|
"content".to_string(),
|
|
simplify_canonical_claude_content(merged_blocks),
|
|
);
|
|
}
|
|
|
|
pub(crate) fn extract_canonical_claude_content_blocks(content: Option<&Value>) -> Vec<Value> {
|
|
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<Value> {
|
|
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::<Vec<_>>();
|
|
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<bool>,
|
|
) -> Option<Value> {
|
|
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<String, Value>,
|
|
) -> 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<String, Value>) -> 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<CanonicalResponseFormat> {
|
|
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<String, Value>,
|
|
format_type: &str,
|
|
) -> Option<Value> {
|
|
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<CanonicalUsage> {
|
|
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<CanonicalUsage> {
|
|
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::<Map<String, Value>>();
|
|
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<CanonicalUsage> {
|
|
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<CanonicalUsage> {
|
|
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<String, Value>, source: &Map<String, Value>) {
|
|
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<CanonicalStopReason> {
|
|
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<CanonicalStopReason> {
|
|
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<CanonicalStopReason> {
|
|
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<Vec<String>> {
|
|
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<String>,
|
|
fallback_media_type: Option<String>,
|
|
) -> (Option<String>, Option<String>, Option<String>) {
|
|
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<String>,
|
|
data: &Option<String>,
|
|
url: &Option<String>,
|
|
) -> 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<String, Value>, key: &str, value: Option<f64>) {
|
|
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<String, Value>,
|
|
handled_keys: &[&str],
|
|
) -> BTreeMap<String, Value> {
|
|
let handled = handled_keys
|
|
.iter()
|
|
.copied()
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
let raw = object
|
|
.iter()
|
|
.filter(|(key, _)| !handled.contains(key.as_str()))
|
|
.map(|(key, value)| (key.clone(), value.clone()))
|
|
.collect::<Map<String, Value>>();
|
|
if raw.is_empty() {
|
|
BTreeMap::new()
|
|
} else {
|
|
BTreeMap::from([("openai".to_string(), Value::Object(raw))])
|
|
}
|
|
}
|
|
|
|
pub(crate) fn claude_extensions(
|
|
object: &Map<String, Value>,
|
|
handled_keys: &[&str],
|
|
) -> BTreeMap<String, Value> {
|
|
let handled = handled_keys
|
|
.iter()
|
|
.copied()
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
let raw = object
|
|
.iter()
|
|
.filter(|(key, _)| !handled.contains(key.as_str()))
|
|
.map(|(key, value)| (key.clone(), value.clone()))
|
|
.collect::<Map<String, Value>>();
|
|
if raw.is_empty() {
|
|
BTreeMap::new()
|
|
} else {
|
|
BTreeMap::from([("claude".to_string(), Value::Object(raw))])
|
|
}
|
|
}
|
|
|
|
pub(crate) fn openai_responses_extensions(
|
|
object: &Map<String, Value>,
|
|
handled_keys: &[&str],
|
|
) -> BTreeMap<String, Value> {
|
|
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<String, Value>,
|
|
handled_keys: &[&str],
|
|
) -> BTreeMap<String, Value> {
|
|
let handled = handled_keys
|
|
.iter()
|
|
.copied()
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
let raw = object
|
|
.iter()
|
|
.filter(|(key, _)| !handled.contains(key.as_str()))
|
|
.map(|(key, value)| (key.clone(), value.clone()))
|
|
.collect::<Map<String, Value>>();
|
|
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<String, Value>,
|
|
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<String, Value>,
|
|
) -> Map<String, Value> {
|
|
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<String> {
|
|
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<String, Value>,
|
|
namespace: &str,
|
|
) -> &'a mut Map<String, Value> {
|
|
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<String, Value>,
|
|
namespace: &str,
|
|
existing: &Map<String, Value>,
|
|
) -> Map<String, Value> {
|
|
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<String, Value>) -> 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<String, Value>,
|
|
) -> 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<String, Value>,
|
|
existing: &Map<String, Value>,
|
|
) -> Map<String, Value> {
|
|
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::<super::CanonicalRequest>(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::<super::CanonicalEmbeddingResponse>(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::<super::CanonicalResponse>(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("<thinking>internal plan</thinking>"));
|
|
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::<Value>(
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
|
|
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::<Vec<_>>();
|
|
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::<Vec<_>>();
|
|
|
|
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::<Vec<_>>();
|
|
|
|
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": "<style></style>"},
|
|
"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");
|
|
}
|
|
}
|