refactor formats adapters and matrix ownership

This commit is contained in:
fawney19
2026-04-26 23:31:28 +08:00
parent c10cd8240e
commit 989b27426b
13 changed files with 2938 additions and 1516 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,184 @@
use serde_json::Value;
use serde_json::{json, Map, Value};
use crate::{
canonical::{canonical_to_claude_request, from_claude_to_canonical_request, CanonicalRequest},
canonical::{
canonical_extension_object_mut, canonical_instructions_to_claude_system,
canonical_messages_to_claude, canonical_openai_reasoning_effort,
canonical_tool_choice_to_claude, canonical_tools_to_claude, claude_extensions,
claude_generation_config, claude_messages_to_canonical, claude_parallel_tool_calls,
claude_system_to_canonical_instructions, claude_thinking_to_canonical,
claude_tool_choice_to_canonical, claude_tools_to_canonical,
compact_canonical_claude_messages, insert_f64, namespace_extension_object,
CanonicalRequest,
},
context::FormatContext,
planner::openai::{
map_openai_reasoning_effort_to_claude_output,
map_openai_reasoning_effort_to_thinking_budget,
},
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
from_claude_to_canonical_request(body)
from_raw(body)
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
canonical_to_claude_request(
to_raw(
request,
ctx.mapped_model_or(request.model.as_str()),
ctx.upstream_is_stream,
)
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
let request = body_json.as_object()?;
let mut canonical = CanonicalRequest {
model: request
.get("model")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
..CanonicalRequest::default()
};
canonical.instructions = claude_system_to_canonical_instructions(request.get("system"))?;
let system_text = canonical
.instructions
.iter()
.map(|instruction| instruction.text.as_str())
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
if !system_text.is_empty() {
canonical.system = Some(system_text);
}
canonical.messages = claude_messages_to_canonical(request.get("messages"))?;
canonical.generation = claude_generation_config(request);
let (tools, builtin_tools, web_search_options) =
claude_tools_to_canonical(request.get("tools"))?;
canonical.tools = tools;
canonical.tool_choice = claude_tool_choice_to_canonical(request.get("tool_choice"));
canonical.parallel_tool_calls = claude_parallel_tool_calls(request.get("tool_choice"));
canonical.metadata = request.get("metadata").cloned();
canonical.thinking = claude_thinking_to_canonical(request);
canonical.extensions = claude_extensions(
request,
&[
"model",
"system",
"messages",
"max_tokens",
"temperature",
"top_p",
"top_k",
"stop",
"stop_sequences",
"stream",
"tools",
"tool_choice",
"metadata",
"thinking",
"output_config",
],
);
if !builtin_tools.is_empty() {
canonical_extension_object_mut(&mut canonical.extensions, "claude")
.insert("builtin_tools".to_string(), Value::Array(builtin_tools));
}
if let Some(web_search_options) = web_search_options {
canonical_extension_object_mut(&mut canonical.extensions, "openai")
.insert("web_search_options".to_string(), web_search_options);
}
if let Some(output_config) = request.get("output_config").cloned() {
canonical_extension_object_mut(&mut canonical.extensions, "claude")
.insert("output_config".to_string(), output_config);
}
Some(canonical)
}
pub fn to_raw(
canonical: &CanonicalRequest,
mapped_model: &str,
upstream_is_stream: bool,
) -> Option<Value> {
let mut output = Map::new();
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
output.insert(
"messages".to_string(),
Value::Array(compact_canonical_claude_messages(
canonical_messages_to_claude(canonical)?,
)),
);
output.insert(
"max_tokens".to_string(),
Value::from(canonical.generation.max_tokens.unwrap_or(1024)),
);
if let Some(system) = canonical_instructions_to_claude_system(&canonical.instructions) {
output.insert("system".to_string(), system);
} else if let Some(system) = canonical
.system
.as_ref()
.filter(|value| !value.trim().is_empty())
{
output.insert("system".to_string(), Value::String(system.clone()));
}
if upstream_is_stream {
output.insert("stream".to_string(), Value::Bool(true));
}
insert_f64(&mut output, "temperature", canonical.generation.temperature);
insert_f64(&mut output, "top_p", canonical.generation.top_p);
if let Some(top_k) = canonical.generation.top_k {
output.insert("top_k".to_string(), Value::from(top_k));
}
if let Some(stop_sequences) = &canonical.generation.stop_sequences {
output.insert(
"stop_sequences".to_string(),
Value::Array(stop_sequences.iter().cloned().map(Value::String).collect()),
);
}
let tools = canonical_tools_to_claude(canonical);
if !tools.is_empty() {
output.insert("tools".to_string(), Value::Array(tools));
}
if let Some(tool_choice) = canonical_tool_choice_to_claude(
canonical.tool_choice.as_ref(),
canonical.parallel_tool_calls,
) {
output.insert("tool_choice".to_string(), tool_choice);
}
if let Some(metadata) = canonical.metadata.clone() {
output.insert("metadata".to_string(), metadata);
}
if let Some(thinking) = canonical.thinking.as_ref() {
let openai_effort = canonical_openai_reasoning_effort(thinking);
let budget_tokens = thinking
.budget_tokens
.or_else(|| openai_effort.and_then(map_openai_reasoning_effort_to_thinking_budget));
if thinking.enabled || budget_tokens.is_some() {
output.insert(
"thinking".to_string(),
json!({
"type": "enabled",
"budget_tokens": budget_tokens.unwrap_or(1024),
}),
);
}
if let Some(output_effort) =
openai_effort.and_then(map_openai_reasoning_effort_to_claude_output)
{
output.insert(
"output_config".to_string(),
json!({
"effort": output_effort,
}),
);
}
}
output.extend(namespace_extension_object(
&canonical.extensions,
"claude",
&output,
));
Some(Value::Object(output))
}

View File

@@ -1,16 +1,97 @@
use serde_json::Value;
use std::collections::BTreeMap;
use serde_json::{json, Value};
use crate::{
canonical::{
canonical_to_claude_response, from_claude_to_canonical_response, CanonicalResponse,
canonical_blocks_to_claude, canonical_stop_reason_to_claude, canonical_usage_to_claude,
claude_content_to_canonical_blocks, claude_extensions, claude_stop_reason_to_canonical,
claude_usage_to_canonical, namespace_extension_object, CanonicalResponse,
CanonicalResponseOutput, CanonicalRole,
},
context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
from_claude_to_canonical_response(body)
from_raw(body)
}
pub fn to(response: &CanonicalResponse, _ctx: &FormatContext) -> Option<Value> {
Some(canonical_to_claude_response(response))
Some(to_raw(response))
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
let body = body_json.as_object()?;
if body.contains_key("error") || body.get("type").and_then(Value::as_str) == Some("error") {
return None;
}
let content = claude_content_to_canonical_blocks(body.get("content"))?;
let stop_reason =
claude_stop_reason_to_canonical(body.get("stop_reason").and_then(Value::as_str));
Some(CanonicalResponse {
id: body
.get("id")
.and_then(Value::as_str)
.unwrap_or("msg-unknown")
.to_string(),
model: body
.get("model")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
outputs: vec![CanonicalResponseOutput {
index: 0,
role: CanonicalRole::Assistant,
content: content.clone(),
stop_reason: stop_reason.clone(),
extensions: BTreeMap::new(),
}],
content,
stop_reason,
usage: claude_usage_to_canonical(body.get("usage")),
extensions: claude_extensions(
body,
&[
"id",
"type",
"role",
"model",
"content",
"stop_reason",
"stop_sequence",
"usage",
],
),
})
}
pub fn to_raw(canonical: &CanonicalResponse) -> Value {
let mut content = canonical_blocks_to_claude(&canonical.content, CanonicalRole::Assistant)
.unwrap_or_default();
if content.is_empty() {
content.push(json!({
"type": "text",
"text": "",
}));
}
let mut response = json!({
"id": canonical.id,
"type": "message",
"role": "assistant",
"model": canonical.model,
"content": content,
"stop_reason": canonical_stop_reason_to_claude(canonical.stop_reason.as_ref()),
"usage": canonical.usage.as_ref().map(canonical_usage_to_claude).unwrap_or_else(|| json!({
"input_tokens": 0,
"output_tokens": 0,
})),
});
if let Some(object) = response.as_object_mut() {
object.extend(namespace_extension_object(
&canonical.extensions,
"claude",
object,
));
}
response
}

View File

@@ -1,18 +1,657 @@
use serde_json::Value;
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use crate::{
canonical::{canonical_to_gemini_request, from_gemini_to_canonical_request, CanonicalRequest},
canonical::{
apply_gemini_request_extensions, canonical_extension_object_mut,
canonical_openai_reasoning_effort, extract_gemini_model_from_path,
gemini_contents_to_canonical_messages, gemini_extensions, gemini_generation_config,
gemini_generation_config_extra, gemini_openai_extra_body,
gemini_response_format_to_canonical, gemini_system_to_canonical_instructions,
gemini_thinking_to_canonical, gemini_tool_choice_to_canonical, gemini_tools_to_canonical,
gemini_value_by_case, CanonicalContentBlock, CanonicalMessage, CanonicalRequest,
CanonicalResponseFormat, CanonicalRole, CanonicalToolChoice, CanonicalToolDefinition,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
context::FormatContext,
planner::openai::map_openai_reasoning_effort_to_gemini_budget,
};
pub fn from(body: &Value, ctx: &FormatContext) -> Option<CanonicalRequest> {
from_gemini_to_canonical_request(body, ctx.request_path.as_deref().unwrap_or_default())
from_raw(body, ctx.request_path.as_deref().unwrap_or_default())
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
canonical_to_gemini_request(
to_raw(
request,
ctx.mapped_model_or(request.model.as_str()),
ctx.upstream_is_stream,
)
}
pub fn from_raw(body_json: &Value, request_path: &str) -> Option<CanonicalRequest> {
let request = body_json.as_object()?;
let mut canonical = CanonicalRequest {
model: request
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| extract_gemini_model_from_path(request_path))
.unwrap_or_default(),
..CanonicalRequest::default()
};
canonical.instructions = gemini_system_to_canonical_instructions(
request
.get("systemInstruction")
.or_else(|| request.get("system_instruction")),
)?;
let system_text = canonical
.instructions
.iter()
.map(|instruction| instruction.text.as_str())
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
if !system_text.is_empty() {
canonical.system = Some(system_text);
}
canonical.messages = gemini_contents_to_canonical_messages(request.get("contents"))?;
canonical.generation = gemini_generation_config(
request
.get("generationConfig")
.or_else(|| request.get("generation_config")),
);
canonical.thinking = gemini_thinking_to_canonical(
request
.get("generationConfig")
.or_else(|| request.get("generation_config")),
);
canonical.response_format = gemini_response_format_to_canonical(
request
.get("generationConfig")
.or_else(|| request.get("generation_config")),
);
let (tools, builtin_tools, web_search_options, raw_tools) =
gemini_tools_to_canonical(request.get("tools"))?;
canonical.tools = tools;
canonical.tool_choice = gemini_tool_choice_to_canonical(
request
.get("toolConfig")
.or_else(|| request.get("tool_config")),
);
canonical.extensions = gemini_extensions(
request,
&[
"model",
"systemInstruction",
"system_instruction",
"contents",
"generationConfig",
"generation_config",
"tools",
"toolConfig",
"tool_config",
"safetySettings",
"safety_settings",
"cachedContent",
"cached_content",
"stream",
],
);
if let Some(generation_config) = request
.get("generationConfig")
.or_else(|| request.get("generation_config"))
.and_then(Value::as_object)
{
let gemini_extension = canonical_extension_object_mut(&mut canonical.extensions, "gemini");
if let Some(thinking_config) =
gemini_value_by_case(generation_config, "thinkingConfig", "thinking_config").cloned()
{
gemini_extension.insert("thinking_config".to_string(), thinking_config);
}
if let Some(response_modalities) = gemini_value_by_case(
generation_config,
"responseModalities",
"response_modalities",
)
.cloned()
{
gemini_extension.insert("response_modalities".to_string(), response_modalities);
}
let extra = gemini_generation_config_extra(generation_config);
if !extra.is_empty() {
gemini_extension.insert("generation_config_extra".to_string(), Value::Object(extra));
}
}
if let Some(value) = request
.get("safetySettings")
.or_else(|| request.get("safety_settings"))
.cloned()
{
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
.insert("safety_settings".to_string(), value);
}
if let Some(value) = request
.get("cachedContent")
.or_else(|| request.get("cached_content"))
.cloned()
{
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
.insert("cached_content".to_string(), value);
}
if let Some(raw_tools) = raw_tools {
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
.insert("raw_tools".to_string(), raw_tools);
}
if !builtin_tools.is_empty() {
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
.insert("builtin_tools".to_string(), Value::Array(builtin_tools));
}
if let Some(tool_config) = request
.get("toolConfig")
.or_else(|| request.get("tool_config"))
.cloned()
{
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
.insert("raw_tool_config".to_string(), tool_config);
}
if let Some(extra_body) = gemini_openai_extra_body(request) {
canonical_extension_object_mut(&mut canonical.extensions, "openai")
.insert("extra_body".to_string(), extra_body);
}
if let Some(web_search_options) = web_search_options {
canonical_extension_object_mut(&mut canonical.extensions, "openai")
.insert("web_search_options".to_string(), web_search_options);
}
Some(canonical)
}
pub fn to_raw(
canonical: &CanonicalRequest,
mapped_model: &str,
upstream_is_stream: bool,
) -> Option<Value> {
let mut output = canonical_to_gemini_request_body(canonical, mapped_model, upstream_is_stream)?;
apply_gemini_request_extensions(&mut output, &canonical.extensions)?;
Some(output)
}
fn canonical_to_gemini_request_body(
canonical: &CanonicalRequest,
mapped_model: &str,
_upstream_is_stream: bool,
) -> Option<Value> {
let mut output = Map::new();
if !mapped_model.trim().is_empty() {
output.insert(
"model".to_string(),
Value::String(mapped_model.trim().to_string()),
);
}
output.insert(
"contents".to_string(),
Value::Array(compact_gemini_contents(
canonical_messages_to_gemini_contents(&canonical.messages)?,
)),
);
if let Some(system_instruction) = canonical_system_instruction(canonical) {
output.insert("systemInstruction".to_string(), system_instruction);
}
if let Some(generation_config) = canonical_generation_config_to_gemini(canonical) {
output.insert("generationConfig".to_string(), generation_config);
}
if let Some(tools) = canonical_tools_to_gemini(canonical) {
output.insert("tools".to_string(), tools);
}
if let Some(tool_config) = canonical_tool_choice_to_gemini(canonical.tool_choice.as_ref()) {
output.insert("toolConfig".to_string(), tool_config);
}
Some(Value::Object(output))
}
fn canonical_system_instruction(canonical: &CanonicalRequest) -> Option<Value> {
let text = canonical
.instructions
.iter()
.map(|instruction| instruction.text.as_str())
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
let text = if text.trim().is_empty() {
canonical.system.as_deref().unwrap_or_default().to_string()
} else {
text
};
(!text.trim().is_empty()).then(|| json!({ "parts": [{ "text": text }] }))
}
fn canonical_messages_to_gemini_contents(messages: &[CanonicalMessage]) -> Option<Vec<Value>> {
let mut contents = Vec::new();
let mut tool_name_by_id = BTreeMap::new();
for message in messages {
let role = match message.role {
CanonicalRole::Assistant => "model",
CanonicalRole::System | CanonicalRole::Developer => continue,
CanonicalRole::Tool | CanonicalRole::User | CanonicalRole::Unknown => "user",
};
let parts = canonical_blocks_to_gemini_parts(&message.content, &mut tool_name_by_id)?;
if parts.is_empty() {
continue;
}
contents.push(json!({
"role": role,
"parts": parts,
}));
}
Some(contents)
}
fn canonical_blocks_to_gemini_parts(
blocks: &[CanonicalContentBlock],
tool_name_by_id: &mut BTreeMap<String, String>,
) -> Option<Vec<Value>> {
let mut parts = Vec::new();
for block in blocks {
if let Some(part) = canonical_block_to_gemini_part(block, tool_name_by_id)? {
parts.push(part);
}
}
Some(parts)
}
fn canonical_block_to_gemini_part(
block: &CanonicalContentBlock,
tool_name_by_id: &mut BTreeMap<String, String>,
) -> Option<Option<Value>> {
match block {
CanonicalContentBlock::Text { text, .. } => Some(Some(json!({ "text": text }))),
CanonicalContentBlock::Thinking {
text, signature, ..
} => {
if text.trim().is_empty() {
return Some(None);
}
let mut part = Map::new();
part.insert("text".to_string(), Value::String(text.clone()));
part.insert("thought".to_string(), Value::Bool(true));
if let Some(signature) = signature.as_ref().filter(|value| !value.is_empty()) {
part.insert(
"thoughtSignature".to_string(),
Value::String(signature.clone()),
);
}
Some(Some(Value::Object(part)))
}
CanonicalContentBlock::Image {
data,
url,
media_type,
..
} => Some(Some(canonical_media_to_gemini_part(
media_type.as_deref().unwrap_or("image/png"),
data.as_deref(),
url.as_deref(),
))),
CanonicalContentBlock::File {
data,
file_url,
media_type,
..
} => Some(Some(canonical_media_to_gemini_part(
media_type.as_deref().unwrap_or("application/octet-stream"),
data.as_deref(),
file_url.as_deref(),
))),
CanonicalContentBlock::Audio {
data, media_type, ..
} => Some(data.as_ref().map(|data| {
json!({
"inlineData": {
"mimeType": media_type.clone().unwrap_or_else(|| "audio/mpeg".to_string()),
"data": data,
}
})
})),
CanonicalContentBlock::ToolUse {
id, name, input, ..
} => {
tool_name_by_id.insert(id.clone(), name.clone());
Some(Some(json!({
"functionCall": {
"id": id,
"name": name,
"args": gemini_function_args(input),
}
})))
}
CanonicalContentBlock::ToolResult {
tool_use_id,
name,
output,
content_text,
..
} => Some(Some(json!({
"functionResponse": {
"id": tool_use_id,
"name": name.clone()
.or_else(|| tool_name_by_id.get(tool_use_id).cloned())
.unwrap_or_else(|| tool_use_id.clone()),
"response": gemini_function_response(output.as_ref(), content_text.as_deref()),
}
}))),
CanonicalContentBlock::Unknown { .. } => Some(None),
}
}
fn canonical_media_to_gemini_part(
media_type: &str,
data: Option<&str>,
url: Option<&str>,
) -> Value {
if let Some(data) = data.filter(|value| !value.is_empty()) {
return json!({
"inlineData": {
"mimeType": media_type,
"data": data,
}
});
}
json!({
"fileData": {
"mimeType": media_type,
"fileUri": url.unwrap_or_default(),
}
})
}
fn canonical_generation_config_to_gemini(canonical: &CanonicalRequest) -> Option<Value> {
let mut generation_config = Map::new();
if let Some(value) = canonical.generation.max_tokens {
generation_config.insert("maxOutputTokens".to_string(), Value::from(value));
}
insert_f64(
&mut generation_config,
"temperature",
canonical.generation.temperature,
);
insert_f64(&mut generation_config, "topP", canonical.generation.top_p);
if let Some(value) = canonical.generation.top_k {
generation_config.insert("topK".to_string(), Value::from(value));
}
if let Some(value) = canonical.generation.n.filter(|value| *value > 1) {
generation_config.insert("candidateCount".to_string(), Value::from(value));
}
if let Some(value) = canonical.generation.seed {
generation_config.insert("seed".to_string(), Value::from(value));
}
if let Some(stop_sequences) = &canonical.generation.stop_sequences {
generation_config.insert(
"stopSequences".to_string(),
Value::Array(stop_sequences.iter().cloned().map(Value::String).collect()),
);
}
if let Some(response_format) = &canonical.response_format {
apply_response_format_to_gemini_generation_config(&mut generation_config, response_format);
}
if let Some(thinking_config) = canonical.thinking.as_ref().and_then(|thinking| {
thinking
.extensions
.get("gemini")
.and_then(|value| value.get("thinking_config"))
.cloned()
.or_else(|| {
let budget = thinking.budget_tokens.or_else(|| {
canonical_openai_reasoning_effort(thinking)
.and_then(map_openai_reasoning_effort_to_gemini_budget)
})?;
Some(json!({
"includeThoughts": true,
"thinkingBudget": budget,
}))
})
}) {
generation_config.insert("thinkingConfig".to_string(), thinking_config);
}
(!generation_config.is_empty()).then_some(Value::Object(generation_config))
}
fn apply_response_format_to_gemini_generation_config(
generation_config: &mut Map<String, Value>,
response_format: &CanonicalResponseFormat,
) {
match response_format.format_type.as_str() {
"json_schema" => {
generation_config.insert(
"responseMimeType".to_string(),
Value::String("application/json".to_string()),
);
if let Some(schema) = response_format
.json_schema
.as_ref()
.and_then(|value| value.get("schema"))
.cloned()
.or_else(|| response_format.json_schema.clone())
{
let mut schema = schema;
clean_gemini_schema(&mut schema);
generation_config.insert("responseSchema".to_string(), schema);
}
}
"json_object" => {
generation_config.insert(
"responseMimeType".to_string(),
Value::String("application/json".to_string()),
);
}
_ => {}
}
}
fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option<Value> {
let mut declarations = Vec::new();
let mut tools = Vec::new();
let mut google_search = canonical
.extensions
.get("openai")
.and_then(Value::as_object)
.is_some_and(|value| value.contains_key("web_search_options"));
let mut code_execution = false;
let mut url_context = false;
for tool in &canonical.tools {
match normalize_gemini_builtin_tool_name(&tool.name) {
Some("googleSearch") => {
google_search = true;
continue;
}
Some("codeExecution") => {
code_execution = true;
continue;
}
Some("urlContext") => {
url_context = true;
continue;
}
Some(_) => continue,
None => {}
}
if tool
.extensions
.get("openai_responses")
.or_else(|| {
tool.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
})
.and_then(|value| value.get("type"))
.and_then(Value::as_str)
.is_some_and(|tool_type| tool_type.starts_with("web_search"))
{
google_search = true;
continue;
}
declarations.push(canonical_tool_to_gemini_declaration(tool));
}
if code_execution {
tools.push(json!({ "codeExecution": {} }));
}
if google_search {
tools.push(json!({ "googleSearch": {} }));
}
if url_context {
tools.push(json!({ "urlContext": {} }));
}
if !declarations.is_empty() {
tools.push(json!({ "functionDeclarations": declarations }));
}
if let Some(builtin_tools) = canonical
.extensions
.get("gemini")
.and_then(Value::as_object)
.and_then(|value| value.get("builtin_tools"))
.and_then(Value::as_array)
{
tools.extend(builtin_tools.iter().cloned());
}
(!tools.is_empty()).then_some(Value::Array(tools))
}
fn canonical_tool_to_gemini_declaration(tool: &CanonicalToolDefinition) -> Value {
let mut declaration = Map::new();
declaration.insert("name".to_string(), Value::String(tool.name.clone()));
if let Some(description) = &tool.description {
declaration.insert(
"description".to_string(),
Value::String(description.clone()),
);
}
declaration.insert(
"parameters".to_string(),
tool.parameters
.clone()
.map(|mut schema| {
clean_gemini_schema(&mut schema);
schema
})
.unwrap_or_else(|| json!({})),
);
Value::Object(declaration)
}
fn canonical_tool_choice_to_gemini(choice: Option<&CanonicalToolChoice>) -> Option<Value> {
let choice = choice?;
let mode = match choice {
CanonicalToolChoice::Auto => "AUTO",
CanonicalToolChoice::None => "NONE",
CanonicalToolChoice::Required | CanonicalToolChoice::Tool { .. } => "ANY",
};
let mut function_calling_config = Map::new();
function_calling_config.insert("mode".to_string(), Value::String(mode.to_string()));
if let CanonicalToolChoice::Tool { name } = choice {
function_calling_config.insert(
"allowedFunctionNames".to_string(),
Value::Array(vec![Value::String(name.clone())]),
);
}
Some(json!({
"functionCallingConfig": Value::Object(function_calling_config),
}))
}
fn gemini_function_args(input: &Value) -> Value {
match input {
Value::Object(_) => input.clone(),
Value::Null => json!({}),
other => json!({ "value": other.clone() }),
}
}
fn gemini_function_response(output: Option<&Value>, content_text: Option<&str>) -> Value {
match output {
Some(value) => json!({ "result": value }),
None => json!({ "result": content_text.unwrap_or_default() }),
}
}
fn compact_gemini_contents(contents: Vec<Value>) -> Vec<Value> {
let mut compact: Vec<Value> = Vec::new();
for content in contents {
let role = content
.get("role")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let parts = content
.get("parts")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
if parts.is_empty() {
continue;
}
if let Some(last) = compact.last_mut() {
let last_role = last.get("role").and_then(Value::as_str).unwrap_or_default();
if last_role == role {
if let Some(last_parts) = last
.as_object_mut()
.and_then(|object| object.get_mut("parts"))
.and_then(Value::as_array_mut)
{
last_parts.extend(parts);
continue;
}
}
}
compact.push(json!({
"role": role,
"parts": parts,
}));
}
compact
}
fn normalize_gemini_builtin_tool_name(name: &str) -> Option<&'static str> {
match name
.trim()
.replace(['_', '-', ' '], "")
.to_ascii_lowercase()
.as_str()
{
"googlesearch" | "websearch" | "websearchpreview" => Some("googleSearch"),
"codeexecution" => Some("codeExecution"),
"urlcontext" => Some("urlContext"),
_ => None,
}
}
fn insert_f64(output: &mut Map<String, Value>, key: &str, value: Option<f64>) {
if let Some(value) = value.and_then(serde_json::Number::from_f64) {
output.insert(key.to_string(), Value::Number(value));
}
}
fn clean_gemini_schema(value: &mut Value) {
match value {
Value::Object(object) => {
for inner in object.values_mut() {
clean_gemini_schema(inner);
}
if object.get("type").and_then(Value::as_str) == Some("object")
&& !object.contains_key("properties")
{
object.insert("properties".to_string(), Value::Object(Map::new()));
}
}
Value::Array(items) => {
for item in items {
clean_gemini_schema(item);
}
}
_ => {}
}
}

View File

@@ -1,16 +1,355 @@
use serde_json::Value;
use serde_json::{json, Map, Value};
use crate::{
canonical::{
canonical_to_gemini_response, from_gemini_to_canonical_response, CanonicalResponse,
canonical_extension_object_mut, gemini_extensions, gemini_part_to_canonical_block,
gemini_stop_reason_to_canonical, gemini_usage_to_canonical, CanonicalContentBlock,
CanonicalResponse, CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
CanonicalUsage,
},
context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
from_gemini_to_canonical_response(body)
from_raw(body)
}
pub fn to(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
canonical_to_gemini_response(response, &ctx.report_context_value())
to_raw(response, &ctx.report_context_value())
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
let body = body_json.as_object()?;
if body.contains_key("error") {
return None;
}
let candidates = body.get("candidates")?.as_array()?;
let mut outputs = Vec::new();
for (fallback_index, candidate) in candidates.iter().enumerate() {
let candidate_object = candidate.as_object()?;
let parts = candidate_object
.get("content")
.and_then(Value::as_object)
.and_then(|content| content.get("parts"))
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
let content = parts
.iter()
.enumerate()
.filter_map(|(index, part)| gemini_part_to_canonical_block(part, index))
.collect::<Vec<_>>();
let mut stop_reason = candidate_object
.get("finishReason")
.or_else(|| candidate_object.get("finish_reason"))
.and_then(Value::as_str)
.and_then(gemini_stop_reason_to_canonical);
if content
.iter()
.any(|block| matches!(block, CanonicalContentBlock::ToolUse { .. }))
&& stop_reason
.as_ref()
.is_none_or(|reason| matches!(reason, CanonicalStopReason::EndTurn))
{
stop_reason = Some(CanonicalStopReason::ToolUse);
}
outputs.push(CanonicalResponseOutput {
index: candidate_object
.get("index")
.and_then(Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
.unwrap_or(fallback_index),
role: CanonicalRole::Assistant,
content,
stop_reason,
extensions: Default::default(),
});
}
let content = outputs
.first()
.map(|output| output.content.clone())
.unwrap_or_default();
let stop_reason = outputs
.first()
.and_then(|output| output.stop_reason.clone());
let mut canonical = CanonicalResponse {
id: body
.get("responseId")
.or_else(|| body.get("_v1internal_response_id"))
.and_then(Value::as_str)
.unwrap_or("gemini-local-finalize")
.to_string(),
model: body
.get("modelVersion")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
outputs,
content,
stop_reason,
usage: gemini_usage_to_canonical(body.get("usageMetadata")),
extensions: gemini_extensions(
body,
&[
"responseId",
"_v1internal_response_id",
"modelVersion",
"candidates",
"usageMetadata",
],
),
};
if let Some(candidates) = body.get("candidates").cloned() {
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
.insert("raw_candidates".to_string(), candidates);
}
Some(canonical)
}
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value) -> Option<Value> {
let mut response = canonical_to_gemini_response(canonical, report_context)?;
if let Some(object) = response.as_object_mut() {
if let Some(gemini) = canonical
.extensions
.get("gemini")
.and_then(Value::as_object)
{
for (key, value) in gemini {
if key == "raw_candidates" || object.contains_key(key) {
continue;
}
object.insert(key.clone(), value.clone());
}
}
}
Some(response)
}
fn canonical_to_gemini_response(
canonical: &CanonicalResponse,
report_context: &Value,
) -> Option<Value> {
let outputs = if canonical.outputs.is_empty() {
vec![CanonicalResponseOutput {
index: 0,
role: crate::canonical::CanonicalRole::Assistant,
content: canonical.content.clone(),
stop_reason: canonical.stop_reason.clone(),
extensions: Default::default(),
}]
} else {
canonical.outputs.clone()
};
let mut candidates = Vec::new();
for output in outputs {
let parts = canonical_blocks_to_gemini_parts(&output.content)?;
candidates.push(json!({
"index": output.index,
"content": {
"role": "model",
"parts": parts,
},
"finishReason": canonical_stop_reason_to_gemini(
output.stop_reason.as_ref().or(canonical.stop_reason.as_ref())
),
}));
}
let mut response = Map::new();
response.insert(
"responseId".to_string(),
Value::String(if canonical.id.trim().is_empty() {
"resp-local-finalize".to_string()
} else {
canonical.id.clone()
}),
);
response.insert(
"modelVersion".to_string(),
Value::String(
if canonical.model.trim().is_empty() || canonical.model == "unknown" {
report_context
.get("mapped_model")
.and_then(Value::as_str)
.or_else(|| report_context.get("model").and_then(Value::as_str))
.unwrap_or("unknown")
.to_string()
} else {
canonical.model.clone()
},
),
);
response.insert("candidates".to_string(), Value::Array(candidates));
if let Some(usage) = &canonical.usage {
response.insert(
"usageMetadata".to_string(),
canonical_usage_to_gemini_usage_metadata(usage),
);
}
Some(Value::Object(response))
}
fn canonical_blocks_to_gemini_parts(blocks: &[CanonicalContentBlock]) -> Option<Vec<Value>> {
let mut parts = Vec::new();
for block in blocks {
if let Some(part) = canonical_block_to_gemini_part(block)? {
parts.push(part);
}
}
if parts.is_empty() {
parts.push(json!({ "text": "" }));
}
Some(parts)
}
fn canonical_block_to_gemini_part(block: &CanonicalContentBlock) -> Option<Option<Value>> {
match block {
CanonicalContentBlock::Text { text, .. } => Some(Some(json!({ "text": text }))),
CanonicalContentBlock::Thinking {
text, signature, ..
} => {
if text.trim().is_empty() {
return Some(None);
}
let mut part = Map::new();
part.insert("text".to_string(), Value::String(text.clone()));
part.insert("thought".to_string(), Value::Bool(true));
if let Some(signature) = signature.as_ref().filter(|value| !value.is_empty()) {
part.insert(
"thoughtSignature".to_string(),
Value::String(signature.clone()),
);
}
Some(Some(Value::Object(part)))
}
CanonicalContentBlock::ToolUse {
id, name, input, ..
} => Some(Some(json!({
"functionCall": {
"id": id,
"name": name,
"args": gemini_function_args(input),
}
}))),
CanonicalContentBlock::ToolResult {
tool_use_id,
name,
output,
content_text,
..
} => Some(Some(json!({
"functionResponse": {
"id": tool_use_id,
"name": name.clone().unwrap_or_else(|| tool_use_id.clone()),
"response": gemini_function_response(output.as_ref(), content_text.as_deref()),
}
}))),
CanonicalContentBlock::Image {
data,
url,
media_type,
..
} => Some(Some(canonical_media_to_gemini_part(
media_type.as_deref().unwrap_or("image/png"),
data.as_deref(),
url.as_deref(),
))),
CanonicalContentBlock::File {
data,
file_url,
media_type,
..
} => Some(Some(canonical_media_to_gemini_part(
media_type.as_deref().unwrap_or("application/octet-stream"),
data.as_deref(),
file_url.as_deref(),
))),
CanonicalContentBlock::Audio {
data, media_type, ..
} => Some(data.as_ref().map(|data| {
json!({
"inlineData": {
"mimeType": media_type.clone().unwrap_or_else(|| "audio/mpeg".to_string()),
"data": data,
}
})
})),
CanonicalContentBlock::Unknown { .. } => Some(None),
}
}
fn canonical_media_to_gemini_part(
media_type: &str,
data: Option<&str>,
url: Option<&str>,
) -> Value {
if let Some(data) = data.filter(|value| !value.is_empty()) {
return json!({
"inlineData": {
"mimeType": media_type,
"data": data,
}
});
}
json!({
"fileData": {
"mimeType": media_type,
"fileUri": url.unwrap_or_default(),
}
})
}
fn gemini_function_args(input: &Value) -> Value {
match input {
Value::Object(_) => input.clone(),
Value::Null => json!({}),
other => json!({ "value": other.clone() }),
}
}
fn gemini_function_response(output: Option<&Value>, content_text: Option<&str>) -> Value {
match output {
Some(Value::Object(object)) => Value::Object(object.clone()),
Some(value) => json!({ "result": value }),
None => json!({ "result": content_text.unwrap_or_default() }),
}
}
fn canonical_stop_reason_to_gemini(reason: Option<&CanonicalStopReason>) -> Value {
Value::String(
match reason {
Some(CanonicalStopReason::MaxTokens) => "MAX_TOKENS",
Some(CanonicalStopReason::ContentFiltered) | Some(CanonicalStopReason::Refusal) => {
"SAFETY"
}
Some(CanonicalStopReason::Unknown) => "OTHER",
_ => "STOP",
}
.to_string(),
)
}
fn canonical_usage_to_gemini_usage_metadata(usage: &CanonicalUsage) -> Value {
let mut out = Map::new();
out.insert(
"promptTokenCount".to_string(),
Value::from(usage.input_tokens),
);
out.insert(
"candidatesTokenCount".to_string(),
Value::from(usage.output_tokens.saturating_sub(usage.reasoning_tokens)),
);
out.insert(
"totalTokenCount".to_string(),
Value::from(usage.total_tokens),
);
if usage.reasoning_tokens > 0 {
out.insert(
"thoughtsTokenCount".to_string(),
Value::from(usage.reasoning_tokens),
);
}
Value::Object(out)
}

View File

@@ -2,21 +2,214 @@ use serde_json::{json, Value};
use crate::{
canonical::{
canonical_to_openai_chat_request, from_openai_chat_to_canonical_request, CanonicalRequest,
canonical_message_to_openai_chat, canonical_response_format_to_openai,
canonical_tool_choice_to_openai, canonical_tool_to_openai, namespace_extension_object,
openai_content_text, openai_extensions, openai_generation_config,
openai_message_content_blocks, openai_response_format_to_canonical,
openai_responses_extension, openai_role_to_canonical, openai_tool_choice_to_canonical,
openai_tools_to_canonical, write_openai_generation_config, CanonicalInstruction,
CanonicalRequest, CanonicalRole, CanonicalThinkingConfig,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
from_openai_chat_to_canonical_request(body)
from_raw(body)
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
let mut body = canonical_to_openai_chat_request(request);
let mut body = to_raw(request);
force_stream_options(&mut body, ctx.upstream_is_stream);
Some(body)
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
let request = body_json.as_object()?;
let mut canonical = CanonicalRequest {
model: request
.get("model")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
..CanonicalRequest::default()
};
if let Some(messages) = request.get("messages").and_then(Value::as_array) {
for message in messages {
let message_object = message.as_object()?;
let role = openai_role_to_canonical(
message_object
.get("role")
.and_then(Value::as_str)
.unwrap_or_default(),
);
if matches!(role, CanonicalRole::System | CanonicalRole::Developer) {
let text = openai_content_text(message_object.get("content"));
canonical.instructions.push(CanonicalInstruction {
role,
text: text.clone(),
extensions: openai_extensions(message_object, &["role", "content"]),
});
if !text.trim().is_empty() {
canonical.system = Some(match canonical.system.take() {
Some(existing) if !existing.trim().is_empty() => {
format!("{existing}\n\n{text}")
}
_ => text,
});
}
continue;
}
canonical.messages.push(crate::canonical::CanonicalMessage {
role,
content: openai_message_content_blocks(message_object)?,
extensions: openai_extensions(
message_object,
&["role", "content", "tool_calls", "tool_call_id"],
),
});
}
}
canonical.generation = openai_generation_config(request);
canonical.tools = openai_tools_to_canonical(request.get("tools"))?;
canonical.tool_choice = openai_tool_choice_to_canonical(request.get("tool_choice"));
canonical.parallel_tool_calls = request.get("parallel_tool_calls").and_then(Value::as_bool);
canonical.metadata = request.get("metadata").cloned();
canonical.response_format = openai_response_format_to_canonical(request.get("response_format"));
if let Some(reasoning_effort) = request.get("reasoning_effort").and_then(Value::as_str) {
let mut extensions = std::collections::BTreeMap::new();
extensions.insert(
"openai".to_string(),
json!({ "reasoning_effort": reasoning_effort }),
);
canonical.thinking = Some(CanonicalThinkingConfig {
enabled: true,
budget_tokens: None,
extensions,
});
}
canonical.extensions = openai_extensions(
request,
&[
"model",
"messages",
"max_tokens",
"max_completion_tokens",
"temperature",
"top_p",
"top_k",
"stop",
"stream",
"tools",
"tool_choice",
"parallel_tool_calls",
"metadata",
"response_format",
"reasoning_effort",
"n",
"presence_penalty",
"frequency_penalty",
"seed",
"logprobs",
"top_logprobs",
],
);
Some(canonical)
}
pub fn to_raw(canonical: &CanonicalRequest) -> Value {
let mut output = serde_json::Map::new();
if !canonical.model.trim().is_empty() {
output.insert("model".to_string(), Value::String(canonical.model.clone()));
}
let mut messages = Vec::new();
for instruction in &canonical.instructions {
let role = match instruction.role {
CanonicalRole::Developer => "developer",
_ => "system",
};
if !instruction.text.trim().is_empty() {
messages.push(json!({
"role": role,
"content": instruction.text,
}));
}
}
for message in &canonical.messages {
messages.push(canonical_message_to_openai_chat(message));
}
output.insert("messages".to_string(), Value::Array(messages));
write_openai_generation_config(&mut output, &canonical.generation);
if !canonical.tools.is_empty() {
output.insert(
"tools".to_string(),
Value::Array(
canonical
.tools
.iter()
.map(canonical_tool_to_openai)
.collect(),
),
);
}
if let Some(tool_choice) = &canonical.tool_choice {
output.insert(
"tool_choice".to_string(),
canonical_tool_choice_to_openai(tool_choice),
);
}
if let Some(value) = canonical.parallel_tool_calls {
output.insert("parallel_tool_calls".to_string(), Value::Bool(value));
}
if let Some(metadata) = canonical.metadata.clone() {
output.insert("metadata".to_string(), metadata);
}
if let Some(response_format) = &canonical.response_format {
output.insert(
"response_format".to_string(),
canonical_response_format_to_openai(response_format),
);
}
if let Some(thinking) = &canonical.thinking {
if let Some(reasoning_effort) = 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)
})
{
output.insert(
"reasoning_effort".to_string(),
Value::String(reasoning_effort.to_string()),
);
}
}
output.extend(namespace_extension_object(
&canonical.extensions,
"openai",
&output,
));
output.extend(namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
&output,
));
output.extend(namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
&output,
));
Value::Object(output)
}
fn force_stream_options(body: &mut Value, upstream_is_stream: bool) {
if !upstream_is_stream {
return;

View File

@@ -1,19 +1,24 @@
use serde_json::Value;
use std::collections::BTreeMap;
use serde_json::{json, Value};
use crate::{
canonical::{
canonical_to_openai_chat_response, from_openai_chat_to_canonical_response,
CanonicalResponse,
canonical_blocks_to_openai_chat_message, canonical_stop_reason_to_openai,
canonical_usage_to_openai, openai_extensions, openai_finish_reason_to_canonical,
openai_message_content_blocks, openai_usage_to_canonical, CanonicalContentBlock,
CanonicalResponse, CanonicalResponseOutput, CanonicalRole,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
from_openai_chat_to_canonical_response(body)
from_raw(body)
}
pub fn to(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
let mut body = canonical_to_openai_chat_response(response);
let mut body = to_raw(response);
if body.get("service_tier").is_none() {
if let Some(service_tier) = ctx
.report_context_value()
@@ -27,3 +32,154 @@ pub fn to(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
}
Some(body)
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
let body = body_json.as_object()?;
if body.contains_key("error") {
return None;
}
let mut outputs = Vec::new();
for (fallback_index, choice_value) in body
.get("choices")
.and_then(Value::as_array)?
.iter()
.enumerate()
{
let choice = choice_value.as_object()?;
let message = choice.get("message").and_then(Value::as_object)?;
let mut content = openai_message_content_blocks(message)?;
if !content
.iter()
.any(|block| matches!(block, CanonicalContentBlock::Thinking { .. }))
{
if let Some(reasoning_content) = message
.get("reasoning_content")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
{
content.insert(
0,
CanonicalContentBlock::Thinking {
text: reasoning_content.to_string(),
signature: None,
encrypted_content: None,
extensions: BTreeMap::new(),
},
);
}
}
let stop_reason =
openai_finish_reason_to_canonical(choice.get("finish_reason").and_then(Value::as_str));
outputs.push(CanonicalResponseOutput {
index: choice
.get("index")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(fallback_index),
role: CanonicalRole::Assistant,
content,
stop_reason,
extensions: BTreeMap::new(),
});
}
let first_output = outputs.first()?;
let content = first_output.content.clone();
let stop_reason = first_output.stop_reason.clone();
Some(CanonicalResponse {
id: body
.get("id")
.and_then(Value::as_str)
.unwrap_or("chatcmpl-unknown")
.to_string(),
model: body
.get("model")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
outputs,
content,
stop_reason,
usage: openai_usage_to_canonical(body.get("usage")),
extensions: openai_extensions(
body,
&["id", "object", "model", "choices", "usage", "created"],
),
})
}
pub fn to_raw(canonical: &CanonicalResponse) -> Value {
let outputs: Vec<CanonicalResponseOutput> = if canonical.outputs.is_empty() {
vec![CanonicalResponseOutput {
index: 0,
role: CanonicalRole::Assistant,
content: canonical.content.clone(),
stop_reason: canonical.stop_reason.clone(),
extensions: BTreeMap::new(),
}]
} else {
canonical.outputs.clone()
};
let choices: Vec<Value> = outputs
.iter()
.enumerate()
.map(|(fallback_index, output)| {
json!({
"index": output.index,
"message": canonical_blocks_to_openai_chat_message(&output.content),
"finish_reason": canonical_stop_reason_to_openai(output.stop_reason.as_ref()),
})
.as_object()
.map(|choice| {
let mut choice = choice.clone();
if output.index == 0 && fallback_index != 0 {
choice.insert("index".to_string(), Value::from(fallback_index as u64));
}
Value::Object(choice)
})
.unwrap_or_else(|| json!({}))
})
.collect();
let mut response = json!({
"id": canonical.id,
"object": "chat.completion",
"model": canonical.model,
"choices": choices,
"usage": canonical.usage.as_ref().map(canonical_usage_to_openai).unwrap_or_else(|| json!({
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
})),
});
if let Some(created_at) = canonical
.extensions
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.or_else(|| {
canonical
.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
})
.and_then(|value| value.get("created_at"))
.and_then(|value| {
value
.as_i64()
.or_else(|| value.as_u64().map(|value| value as i64))
})
{
response["created"] = Value::from(created_at);
}
if let Some(service_tier) = canonical
.extensions
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.or_else(|| {
canonical
.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
})
.and_then(|value| value.get("service_tier"))
.cloned()
{
response["service_tier"] = service_tier;
}
response
}

View File

@@ -1,28 +1,501 @@
use serde_json::Value;
use serde_json::{json, Map, Value};
use crate::{
canonical::{
canonical_to_openai_responses_compact_request, canonical_to_openai_responses_request,
from_openai_responses_to_canonical_request, CanonicalRequest,
canonical_response_format_to_openai, canonicalize_tool_arguments, media_data_or_url,
namespace_extension_object, openai_content_text, openai_extensions,
openai_response_format_to_canonical, openai_responses_extension,
openai_responses_generation_config, openai_responses_input_to_canonical_messages,
openai_responses_tool_choice_to_canonical, openai_responses_tools_to_canonical,
CanonicalContentBlock, CanonicalInstruction, CanonicalRequest, CanonicalRole,
CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
context::FormatContext,
planner::openai::map_thinking_budget_to_openai_reasoning_effort,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
from_openai_responses_to_canonical_request(body)
from_raw(body)
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
canonical_to_openai_responses_request(
to_raw(
request,
ctx.mapped_model_or(request.model.as_str()),
ctx.upstream_is_stream,
false,
)
}
pub fn to_compact(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
canonical_to_openai_responses_compact_request(
to_raw(
request,
ctx.mapped_model_or(request.model.as_str()),
false,
true,
)
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
let request = body_json.as_object()?;
let mut canonical = CanonicalRequest {
model: request
.get("model")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
..CanonicalRequest::default()
};
if let Some(instructions) = request.get("instructions") {
let text = openai_content_text(Some(instructions));
if !text.trim().is_empty() {
canonical.system = Some(text.clone());
canonical.instructions.push(CanonicalInstruction {
role: CanonicalRole::System,
text,
extensions: std::collections::BTreeMap::new(),
});
}
}
canonical.messages = openai_responses_input_to_canonical_messages(request.get("input"))?;
canonical.generation = openai_responses_generation_config(request);
canonical.tools = openai_responses_tools_to_canonical(request.get("tools"))?;
canonical.tool_choice = openai_responses_tool_choice_to_canonical(request.get("tool_choice"));
canonical.parallel_tool_calls = request.get("parallel_tool_calls").and_then(Value::as_bool);
canonical.metadata = request.get("metadata").cloned();
canonical.response_format = request
.get("text")
.and_then(Value::as_object)
.and_then(|text| text.get("format"))
.and_then(|format| openai_response_format_to_canonical(Some(format)));
if let Some(reasoning) = request.get("reasoning").and_then(Value::as_object) {
let mut extensions = std::collections::BTreeMap::new();
extensions.insert(
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
Value::Object(reasoning.clone()),
);
canonical.thinking = Some(CanonicalThinkingConfig {
enabled: true,
budget_tokens: reasoning.get("budget_tokens").and_then(Value::as_u64),
extensions,
});
}
canonical.extensions = openai_extensions(
request,
&[
"model",
"instructions",
"input",
"max_output_tokens",
"temperature",
"top_p",
"metadata",
"tools",
"tool_choice",
"parallel_tool_calls",
"text",
"reasoning",
],
);
if let Some(raw) = canonical.extensions.remove("openai") {
canonical
.extensions
.insert(OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(), raw);
}
if let Some(verbosity) = request
.get("text")
.and_then(Value::as_object)
.and_then(|text| text.get("verbosity"))
.cloned()
{
let entry = canonical
.extensions
.entry(OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string())
.or_insert_with(|| Value::Object(serde_json::Map::new()));
if let Some(object) = entry.as_object_mut() {
object.insert("verbosity".to_string(), verbosity);
}
}
Some(canonical)
}
pub fn to_raw(
canonical: &CanonicalRequest,
mapped_model: &str,
upstream_is_stream: bool,
compact: bool,
) -> Option<Value> {
let mut output = Map::new();
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
if let Some(instructions) = canonical_instructions_to_responses(canonical) {
output.insert("instructions".to_string(), instructions);
}
output.insert(
"input".to_string(),
Value::Array(canonical_messages_to_responses_input(canonical)?),
);
if upstream_is_stream && !compact {
output.insert("stream".to_string(), Value::Bool(true));
}
if let Some(max_tokens) = canonical.generation.max_tokens {
output.insert("max_output_tokens".to_string(), Value::from(max_tokens));
}
insert_number(&mut output, "temperature", canonical.generation.temperature);
insert_number(&mut output, "top_p", canonical.generation.top_p);
if let Some(top_logprobs) = canonical.generation.top_logprobs {
output.insert("top_logprobs".to_string(), Value::from(top_logprobs));
}
if let Some(value) = canonical.parallel_tool_calls {
output.insert("parallel_tool_calls".to_string(), Value::Bool(value));
}
if let Some(metadata) = canonical.metadata.clone() {
output.insert("metadata".to_string(), metadata);
}
if let Some(text_config) = canonical_text_config_to_responses(canonical) {
output.insert("text".to_string(), text_config);
}
if !canonical.tools.is_empty() {
output.insert(
"tools".to_string(),
Value::Array(canonical_tools_to_responses(canonical)),
);
}
if let Some(tool_choice) = canonical.tool_choice.as_ref() {
output.insert(
"tool_choice".to_string(),
canonical_tool_choice_to_responses(tool_choice),
);
}
if let Some(reasoning) = canonical
.thinking
.as_ref()
.and_then(reasoning_config_to_responses)
{
output.insert("reasoning".to_string(), reasoning);
}
output.extend(namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
&output,
));
output.extend(namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
&output,
));
output.remove("verbosity");
Some(Value::Object(output))
}
fn canonical_instructions_to_responses(canonical: &CanonicalRequest) -> Option<Value> {
let text = canonical
.instructions
.iter()
.map(|instruction| instruction.text.as_str())
.filter(|text| !text.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
if !text.trim().is_empty() {
return Some(Value::String(text));
}
canonical
.system
.as_ref()
.filter(|value| !value.trim().is_empty())
.cloned()
.map(Value::String)
}
fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option<Vec<Value>> {
let mut input = Vec::new();
for message in &canonical.messages {
let role = match message.role {
CanonicalRole::Assistant => "assistant",
CanonicalRole::Tool | CanonicalRole::User | CanonicalRole::Unknown => "user",
CanonicalRole::System | CanonicalRole::Developer => continue,
};
let mut content = Vec::new();
for block in &message.content {
match block {
CanonicalContentBlock::ToolUse {
id,
name,
input: arguments,
..
} => {
flush_responses_message(&mut input, role, &mut content);
input.push(json!({
"type": "function_call",
"call_id": id,
"name": name,
"arguments": canonicalize_tool_arguments(arguments),
}));
}
CanonicalContentBlock::ToolResult {
tool_use_id,
output,
content_text,
..
} => {
flush_responses_message(&mut input, role, &mut content);
input.push(json!({
"type": "function_call_output",
"call_id": tool_use_id,
"output": responses_tool_result_output(output.as_ref(), content_text.as_deref()),
}));
}
CanonicalContentBlock::Thinking { .. } => {}
other => {
if let Some(part) = canonical_block_to_responses_input_part(other, role) {
content.push(part);
}
}
}
}
flush_responses_message(&mut input, role, &mut content);
}
Some(input)
}
fn flush_responses_message(input: &mut Vec<Value>, role: &str, content: &mut Vec<Value>) {
if content.is_empty() {
return;
}
input.push(json!({
"type": "message",
"role": role,
"content": std::mem::take(content),
}));
}
fn canonical_block_to_responses_input_part(
block: &CanonicalContentBlock,
role: &str,
) -> Option<Value> {
match block {
CanonicalContentBlock::Text { text, .. } => {
if text.is_empty() {
return None;
}
Some(json!({
"type": if role == "assistant" { "output_text" } else { "input_text" },
"text": text,
}))
}
CanonicalContentBlock::Image {
data,
url,
media_type,
detail,
..
} => {
let mut item = Map::new();
item.insert(
"type".to_string(),
Value::String(if role == "assistant" {
"output_image".to_string()
} else {
"input_image".to_string()
}),
);
item.insert(
"image_url".to_string(),
Value::String(media_data_or_url(media_type, data, url)),
);
if let Some(detail) = detail {
item.insert("detail".to_string(), Value::String(detail.clone()));
}
Some(Value::Object(item))
}
CanonicalContentBlock::File {
data,
file_id,
file_url,
media_type,
filename,
..
} => {
let mut item = Map::new();
item.insert("type".to_string(), Value::String("input_file".to_string()));
if let Some(value) = file_id {
item.insert("file_id".to_string(), Value::String(value.clone()));
}
if data.is_some() || file_url.is_some() {
item.insert(
"file_data".to_string(),
Value::String(media_data_or_url(media_type, data, file_url)),
);
}
if let Some(value) = filename {
item.insert("filename".to_string(), Value::String(value.clone()));
}
(item.len() > 1).then_some(Value::Object(item))
}
CanonicalContentBlock::Audio { data, format, .. } => Some(json!({
"type": "input_audio",
"input_audio": {
"data": data.clone().unwrap_or_default(),
"format": format.clone().unwrap_or_else(|| "mp3".to_string()),
}
})),
CanonicalContentBlock::Unknown {
raw_type, payload, ..
} if raw_type == "refusal" => payload
.get("refusal")
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty())
.map(|text| json!({ "type": "refusal", "refusal": text })),
CanonicalContentBlock::Thinking { .. }
| CanonicalContentBlock::ToolUse { .. }
| CanonicalContentBlock::ToolResult { .. }
| CanonicalContentBlock::Unknown { .. } => None,
}
}
fn canonical_tools_to_responses(canonical: &CanonicalRequest) -> Vec<Value> {
let mut tools = canonical
.tools
.iter()
.map(canonical_tool_to_responses)
.collect::<Vec<_>>();
if let Some(extra_tools) = canonical
.extensions
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.or_else(|| {
canonical
.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
})
.and_then(Value::as_object)
.and_then(|value| value.get("tools"))
.and_then(Value::as_array)
{
tools.extend(extra_tools.iter().cloned());
}
tools
}
fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<Value> {
openai_responses_extension(&thinking.extensions)
.cloned()
.or_else(|| {
thinking
.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
.cloned()
})
.or_else(|| {
thinking
.extensions
.get("openai")
.and_then(|value| value.get("reasoning_effort"))
.and_then(Value::as_str)
.map(|effort| {
json!({
"effort": if effort == "xhigh" { "high" } else { effort },
})
})
})
.or_else(|| {
thinking.budget_tokens.map(|budget_tokens| {
json!({
"effort": map_thinking_budget_to_openai_reasoning_effort(budget_tokens),
})
})
})
}
fn canonical_text_config_to_responses(canonical: &CanonicalRequest) -> Option<Value> {
let mut text = Map::new();
if let Some(response_format) = &canonical.response_format {
text.insert(
"format".to_string(),
canonical_response_format_to_openai(response_format),
);
}
if let Some(verbosity) = canonical
.extensions
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.or_else(|| {
canonical
.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
})
.and_then(Value::as_object)
.and_then(|value| value.get("verbosity"))
.cloned()
{
text.insert("verbosity".to_string(), verbosity);
}
(!text.is_empty()).then_some(Value::Object(text))
}
fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
if let Some(raw) = tool
.extensions
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.or_else(|| {
tool.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
})
.filter(|value| {
value
.get("type")
.and_then(Value::as_str)
.is_some_and(|tool_type| tool_type.starts_with("web_search"))
})
{
return raw.clone();
}
let mut out = Map::new();
out.insert("type".to_string(), Value::String("function".to_string()));
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()),
);
}
if let Some(parameters) = &tool.parameters {
out.insert("parameters".to_string(), parameters.clone());
}
out.extend(namespace_extension_object(
&tool.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
&out,
));
Value::Object(out)
}
fn canonical_tool_choice_to_responses(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",
"name": name,
}),
}
}
fn responses_tool_result_output(output: Option<&Value>, content_text: Option<&str>) -> Value {
match output {
Some(Value::String(text)) => Value::String(text.clone()),
Some(value) => serde_json::to_string(value)
.map(Value::String)
.unwrap_or_else(|_| Value::String(String::new())),
None => Value::String(content_text.unwrap_or_default().to_string()),
}
}
fn insert_number(output: &mut Map<String, Value>, key: &str, value: Option<f64>) {
if let Some(value) = value.and_then(serde_json::Number::from_f64) {
output.insert(key.to_string(), Value::Number(value));
}
}

View File

@@ -1,27 +1,248 @@
use serde_json::Value;
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use crate::{
canonical::{
canonical_to_openai_responses_compact_response, canonical_to_openai_responses_response,
from_openai_responses_to_canonical_response, CanonicalResponse,
canonical_content_block_to_openai_responses_part,
canonical_usage_to_openai_responses_usage, canonicalize_tool_arguments,
flush_openai_responses_message_item, namespace_extension_object,
openai_responses_extensions, openai_responses_output_to_canonical_blocks,
openai_usage_to_canonical, CanonicalContentBlock, CanonicalResponse,
CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
context::FormatContext,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
from_openai_responses_to_canonical_response(body)
from_raw(body)
}
pub fn to(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
Some(canonical_to_openai_responses_response(
response,
&ctx.report_context_value(),
))
Some(to_raw(response, &ctx.report_context_value(), false))
}
pub fn to_compact(response: &CanonicalResponse, ctx: &FormatContext) -> Option<Value> {
Some(canonical_to_openai_responses_compact_response(
response,
&ctx.report_context_value(),
))
Some(to_raw(response, &ctx.report_context_value(), true))
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
let body = body_json.as_object()?;
if body.contains_key("error") || body.get("status").and_then(Value::as_str) == Some("failed") {
return None;
}
let content = openai_responses_output_to_canonical_blocks(body.get("output"))?;
let has_tool_use = content
.iter()
.any(|block| matches!(block, CanonicalContentBlock::ToolUse { .. }));
let stop_reason = if has_tool_use {
Some(CanonicalStopReason::ToolUse)
} else {
match body.get("status").and_then(Value::as_str) {
Some("incomplete") => Some(CanonicalStopReason::MaxTokens),
Some("failed") => Some(CanonicalStopReason::Unknown),
_ => Some(CanonicalStopReason::EndTurn),
}
};
Some(CanonicalResponse {
id: body
.get("id")
.and_then(Value::as_str)
.unwrap_or("resp-unknown")
.to_string(),
model: body
.get("model")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string(),
outputs: vec![CanonicalResponseOutput {
index: 0,
role: CanonicalRole::Assistant,
content: content.clone(),
stop_reason: stop_reason.clone(),
extensions: BTreeMap::new(),
}],
content,
stop_reason,
usage: openai_usage_to_canonical(body.get("usage")),
extensions: openai_responses_extensions(
body,
&["id", "object", "model", "output", "usage", "status"],
),
})
}
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: bool) -> Value {
let mut response = Map::new();
let response_id = canonical.id.replace("chatcmpl", "resp");
response.insert("id".to_string(), Value::String(response_id.clone()));
response.insert("object".to_string(), Value::String("response".to_string()));
response.insert("status".to_string(), Value::String("completed".to_string()));
response.insert("model".to_string(), Value::String(canonical.model.clone()));
let mut output = Vec::new();
let mut message_content = Vec::new();
let mut message_index = 0usize;
for block in &canonical.content {
match block {
CanonicalContentBlock::Text { .. }
| CanonicalContentBlock::Image { .. }
| CanonicalContentBlock::File { .. }
| CanonicalContentBlock::Audio { .. } => {
if let Some(part) = canonical_content_block_to_openai_responses_part(block) {
message_content.push(part);
}
}
CanonicalContentBlock::Thinking {
text,
encrypted_content,
..
} => {
flush_openai_responses_message_item(
&mut output,
&mut message_content,
&response_id,
&mut message_index,
);
let mut item = Map::new();
item.insert("type".to_string(), Value::String("reasoning".to_string()));
item.insert(
"id".to_string(),
Value::String(format!("{}_rs_{}", response_id, output.len())),
);
item.insert("status".to_string(), Value::String("completed".to_string()));
if let Some(encrypted_content) =
encrypted_content.as_ref().filter(|value| !value.is_empty())
{
item.insert(
"encrypted_content".to_string(),
Value::String(encrypted_content.clone()),
);
}
if !text.trim().is_empty() {
item.insert(
"summary".to_string(),
Value::Array(vec![json!({
"type": "summary_text",
"text": text,
})]),
);
}
output.push(Value::Object(item));
}
CanonicalContentBlock::ToolUse {
id, name, input, ..
} => {
flush_openai_responses_message_item(
&mut output,
&mut message_content,
&response_id,
&mut message_index,
);
output.push(json!({
"type": "function_call",
"id": id,
"call_id": id,
"name": name,
"arguments": canonicalize_tool_arguments(input),
}));
}
CanonicalContentBlock::ToolResult {
tool_use_id,
output: result_output,
content_text,
is_error,
..
} => {
flush_openai_responses_message_item(
&mut output,
&mut message_content,
&response_id,
&mut message_index,
);
let mut item = Map::new();
item.insert(
"type".to_string(),
Value::String("function_call_output".to_string()),
);
item.insert("call_id".to_string(), Value::String(tool_use_id.clone()));
item.insert(
"output".to_string(),
result_output
.clone()
.unwrap_or_else(|| Value::String(content_text.clone().unwrap_or_default())),
);
if *is_error {
item.insert("is_error".to_string(), Value::Bool(true));
}
output.push(Value::Object(item));
}
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() {
message_content.push(json!({
"type": "refusal",
"refusal": text,
}));
}
}
}
CanonicalContentBlock::Unknown { .. } => {}
}
}
flush_openai_responses_message_item(
&mut output,
&mut message_content,
&response_id,
&mut message_index,
);
response.insert("output".to_string(), Value::Array(output));
if let Some(usage) = &canonical.usage {
response.insert(
"usage".to_string(),
canonical_usage_to_openai_responses_usage(usage),
);
}
if let Some(request_object) = report_context
.get("original_request_body")
.and_then(Value::as_object)
{
for key in [
"instructions",
"max_output_tokens",
"parallel_tool_calls",
"previous_response_id",
"reasoning",
"store",
"temperature",
"text",
"tool_choice",
"tools",
"top_p",
"truncation",
"user",
"metadata",
] {
if let Some(value) = request_object.get(key) {
response.insert(key.to_string(), value.clone());
}
}
if let Some(service_tier) = request_object.get("service_tier").cloned() {
response.insert("service_tier".to_string(), service_tier);
}
}
response.extend(namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
&response,
));
response.extend(namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
&response,
));
Value::Object(response)
}

View File

@@ -2,6 +2,7 @@ pub mod canonical;
pub mod context;
pub mod conversion;
pub mod formats;
pub mod matrix;
pub mod planner;
pub mod proxy;
pub mod registry;
@@ -30,4 +31,10 @@ pub use formats::{
normalize_legacy_openai_format_alias, openai_format_storage_aliases, FormatFamily, FormatId,
FormatProfile,
};
pub use matrix::{
request_candidate_api_format_preference, request_candidate_api_formats,
request_conversion_kind, request_conversion_requires_enable_flag,
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, RequestConversionKind,
SyncChatResponseConversionKind, SyncCliResponseConversionKind,
};
pub use registry::{build_stream_transcoder, convert_request, convert_response};

View File

@@ -0,0 +1,436 @@
use crate::{
formats::{is_openai_responses_compact_format, normalize_legacy_openai_format_alias},
legacy_openai_format_alias_matches,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestConversionKind {
ToOpenAIChat,
ToOpenAiResponses,
ToClaudeStandard,
ToGeminiStandard,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncChatResponseConversionKind {
ToOpenAIChat,
ToClaudeChat,
ToGeminiChat,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncCliResponseConversionKind {
ToOpenAiResponses,
ToClaudeCli,
ToGeminiCli,
}
const NON_COMPACT_STANDARD_CANDIDATE_API_FORMATS: &[&str] = &[
"openai:chat",
"openai:responses",
"claude:chat",
"claude:cli",
"gemini:chat",
"gemini:cli",
];
const STANDARD_API_FAMILY_ORDER: &[&str] = &["openai", "claude", "gemini"];
pub fn request_candidate_api_format_preference(
client_api_format: &str,
provider_api_format: &str,
) -> Option<(u8, u8)> {
let client_api_format = normalize_legacy_openai_format_alias(client_api_format);
let provider_api_format = normalize_legacy_openai_format_alias(provider_api_format);
if client_api_format == "openai:responses:compact" {
return (provider_api_format == "openai:responses:compact").then_some((0, 0));
}
let (client_family, client_kind) =
parse_non_compact_standard_api_format(client_api_format.as_str())?;
let (provider_family, provider_kind) =
parse_non_compact_standard_api_format(provider_api_format.as_str())?;
let preference_bucket = if client_api_format == provider_api_format {
0
} else if client_kind == provider_kind {
1
} else if client_family == provider_family {
2
} else {
3
};
Some((
preference_bucket,
standard_api_family_priority(provider_family),
))
}
pub fn request_candidate_api_formats(
client_api_format: &str,
_require_streaming: bool,
) -> Vec<&'static str> {
let client_api_format = normalize_legacy_openai_format_alias(client_api_format);
if client_api_format == "openai:responses:compact" {
return vec!["openai:responses:compact"];
}
if parse_non_compact_standard_api_format(client_api_format.as_str()).is_none() {
return Vec::new();
}
let mut candidate_api_formats = NON_COMPACT_STANDARD_CANDIDATE_API_FORMATS.to_vec();
candidate_api_formats.sort_by_key(|provider_api_format| {
request_candidate_api_format_preference(client_api_format.as_str(), provider_api_format)
.unwrap_or((u8::MAX, u8::MAX))
});
candidate_api_formats
}
pub fn request_conversion_kind(
client_api_format: &str,
provider_api_format: &str,
) -> Option<RequestConversionKind> {
let client_api_format = normalize_legacy_openai_format_alias(client_api_format);
let provider_api_format = normalize_legacy_openai_format_alias(provider_api_format);
if client_api_format == provider_api_format {
return None;
}
if !is_standard_api_format(client_api_format.as_str())
|| !is_standard_api_format(provider_api_format.as_str())
{
return None;
}
if is_openai_responses_compact_format(client_api_format.as_str())
|| is_openai_responses_compact_format(provider_api_format.as_str())
{
return None;
}
match provider_api_format.as_str() {
"openai:chat" => Some(RequestConversionKind::ToOpenAIChat),
"openai:responses" => Some(RequestConversionKind::ToOpenAiResponses),
"claude:chat" | "claude:cli" => Some(RequestConversionKind::ToClaudeStandard),
"gemini:chat" | "gemini:cli" => Some(RequestConversionKind::ToGeminiStandard),
_ => None,
}
}
pub fn sync_chat_response_conversion_kind(
provider_api_format: &str,
client_api_format: &str,
) -> Option<SyncChatResponseConversionKind> {
let provider_api_format = normalize_legacy_openai_format_alias(provider_api_format);
let client_api_format = normalize_legacy_openai_format_alias(client_api_format);
if provider_api_format == client_api_format {
return None;
}
if !is_standard_api_format(provider_api_format.as_str()) {
return None;
}
request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str())?;
match client_api_format.as_str() {
"openai:chat" => Some(SyncChatResponseConversionKind::ToOpenAIChat),
"claude:chat" => Some(SyncChatResponseConversionKind::ToClaudeChat),
"gemini:chat" => Some(SyncChatResponseConversionKind::ToGeminiChat),
_ => None,
}
}
pub fn sync_cli_response_conversion_kind(
provider_api_format: &str,
client_api_format: &str,
) -> Option<SyncCliResponseConversionKind> {
let provider_api_format = normalize_legacy_openai_format_alias(provider_api_format);
let client_api_format = normalize_legacy_openai_format_alias(client_api_format);
if provider_api_format == client_api_format {
return None;
}
if !is_standard_api_format(provider_api_format.as_str()) {
return None;
}
if !is_openai_responses_compact_format(client_api_format.as_str()) {
request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str())?;
}
match client_api_format.as_str() {
"openai:responses" | "openai:responses:compact" => {
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
}
"claude:cli" => Some(SyncCliResponseConversionKind::ToClaudeCli),
"gemini:cli" => Some(SyncCliResponseConversionKind::ToGeminiCli),
_ => None,
}
}
pub fn request_conversion_requires_enable_flag(
client_api_format: &str,
provider_api_format: &str,
) -> bool {
let client_api_format = normalize_legacy_openai_format_alias(client_api_format);
let provider_api_format = normalize_legacy_openai_format_alias(provider_api_format);
match (
api_data_format_id(client_api_format.as_str()),
api_data_format_id(provider_api_format.as_str()),
) {
(Some(client_data_format), Some(provider_data_format)) => {
client_data_format != provider_data_format
}
_ => true,
}
}
pub fn is_standard_api_format(api_format: &str) -> bool {
matches!(
normalize_legacy_openai_format_alias(api_format).as_str(),
"openai:chat"
| "openai:responses"
| "openai:responses:compact"
| "claude:chat"
| "claude:cli"
| "gemini:chat"
| "gemini:cli"
)
}
pub fn parse_non_compact_standard_api_format(
api_format: &str,
) -> Option<(&'static str, &'static str)> {
match normalize_legacy_openai_format_alias(api_format).as_str() {
"openai:chat" => Some(("openai", "chat")),
"openai:responses" => Some(("openai", "cli")),
"claude:chat" => Some(("claude", "chat")),
"claude:cli" => Some(("claude", "cli")),
"gemini:chat" => Some(("gemini", "chat")),
"gemini:cli" => Some(("gemini", "cli")),
_ => None,
}
}
pub fn api_data_format_id(api_format: &str) -> Option<&'static str> {
match normalize_legacy_openai_format_alias(api_format).as_str() {
"claude:chat" | "claude:cli" => Some("claude"),
"gemini:chat" | "gemini:cli" => Some("gemini"),
"openai:chat" => Some("openai_chat"),
"openai:responses" | "openai:responses:compact" => Some("openai_responses"),
_ => None,
}
}
pub fn normalized_same_standard_api_format(left: &str, right: &str) -> bool {
legacy_openai_format_alias_matches(left, right)
}
fn standard_api_family_priority(family: &str) -> u8 {
STANDARD_API_FAMILY_ORDER
.iter()
.position(|candidate| *candidate == family)
.unwrap_or(STANDARD_API_FAMILY_ORDER.len()) as u8
}
#[cfg(test)]
mod tests {
use super::{
request_candidate_api_format_preference, request_candidate_api_formats,
request_conversion_kind, request_conversion_requires_enable_flag,
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind,
RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
};
fn expected_request_conversion_kind(provider_api_format: &str) -> RequestConversionKind {
match provider_api_format {
"openai:chat" => RequestConversionKind::ToOpenAIChat,
"openai:responses" => RequestConversionKind::ToOpenAiResponses,
"claude:chat" | "claude:cli" => RequestConversionKind::ToClaudeStandard,
"gemini:chat" | "gemini:cli" => RequestConversionKind::ToGeminiStandard,
other => panic!("unexpected provider format {other}"),
}
}
#[test]
fn request_conversion_registry_supports_bidirectional_standard_matrix() {
assert_eq!(
request_conversion_kind("openai:chat", "openai:responses"),
Some(RequestConversionKind::ToOpenAiResponses)
);
assert_eq!(
request_conversion_kind("openai:chat", "claude:cli"),
Some(RequestConversionKind::ToClaudeStandard)
);
assert_eq!(
request_conversion_kind("openai:responses", "openai:chat"),
Some(RequestConversionKind::ToOpenAIChat)
);
assert_eq!(
request_conversion_kind("openai:responses:compact", "gemini:cli"),
None
);
assert_eq!(
request_conversion_kind("gemini:cli", "openai:responses:compact"),
None
);
assert_eq!(
request_conversion_kind("openai:chat", "openai:responses:compact"),
None
);
assert_eq!(
request_conversion_kind("openai:responses", "openai:cli"),
None
);
assert_eq!(
request_conversion_kind("openai:compact", "openai:responses:compact"),
None
);
assert_eq!(
request_conversion_kind("gemini:chat", "claude:chat"),
Some(RequestConversionKind::ToClaudeStandard)
);
assert_eq!(
request_conversion_kind("claude:chat", "claude:cli"),
Some(RequestConversionKind::ToClaudeStandard)
);
assert_eq!(request_conversion_kind("claude:chat", "claude:chat"), None);
let formats = [
"openai:chat",
"openai:responses",
"claude:chat",
"claude:cli",
"gemini:chat",
"gemini:cli",
];
for client_api_format in formats {
for provider_api_format in formats {
let actual = request_conversion_kind(client_api_format, provider_api_format);
if client_api_format == provider_api_format {
assert_eq!(actual, None, "{client_api_format} -> {provider_api_format}");
} else {
assert_eq!(
actual,
Some(expected_request_conversion_kind(provider_api_format)),
"{client_api_format} -> {provider_api_format}"
);
}
}
}
}
#[test]
fn sync_response_conversion_registry_supports_bidirectional_standard_matrix() {
assert_eq!(
sync_chat_response_conversion_kind("openai:chat", "claude:chat"),
Some(SyncChatResponseConversionKind::ToClaudeChat)
);
assert_eq!(
sync_chat_response_conversion_kind("claude:chat", "gemini:chat"),
Some(SyncChatResponseConversionKind::ToGeminiChat)
);
assert_eq!(
sync_chat_response_conversion_kind("gemini:chat", "openai:chat"),
Some(SyncChatResponseConversionKind::ToOpenAIChat)
);
assert_eq!(
sync_cli_response_conversion_kind("openai:responses", "gemini:cli"),
Some(SyncCliResponseConversionKind::ToGeminiCli)
);
assert_eq!(
sync_cli_response_conversion_kind("claude:chat", "openai:responses"),
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
);
assert_eq!(
sync_cli_response_conversion_kind("claude:cli", "openai:responses:compact"),
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
);
assert_eq!(
sync_cli_response_conversion_kind("openai:responses:compact", "claude:cli"),
None
);
assert_eq!(
sync_cli_response_conversion_kind("gemini:cli", "claude:cli"),
Some(SyncCliResponseConversionKind::ToClaudeCli)
);
assert_eq!(
sync_cli_response_conversion_kind("openai:responses", "openai:cli"),
None
);
assert_eq!(
sync_cli_response_conversion_kind("openai:compact", "openai:responses:compact"),
None
);
}
#[test]
fn request_candidate_registry_prefers_same_kind_before_same_family_fallbacks() {
assert_eq!(
request_candidate_api_formats("openai:chat", false),
vec![
"openai:chat",
"claude:chat",
"gemini:chat",
"openai:responses",
"claude:cli",
"gemini:cli"
]
);
assert_eq!(
request_candidate_api_formats("openai:responses", false),
vec![
"openai:responses",
"claude:cli",
"gemini:cli",
"openai:chat",
"claude:chat",
"gemini:chat"
]
);
assert_eq!(
request_candidate_api_formats("openai:cli", false),
request_candidate_api_formats("openai:responses", false)
);
assert_eq!(
request_candidate_api_formats("claude:cli", false),
vec![
"claude:cli",
"openai:responses",
"gemini:cli",
"claude:chat",
"openai:chat",
"gemini:chat"
]
);
assert_eq!(
request_candidate_api_formats("openai:compact", false),
vec!["openai:responses:compact"]
);
assert_eq!(
request_candidate_api_format_preference("claude:cli", "openai:responses"),
Some((1, 0))
);
assert_eq!(
request_candidate_api_format_preference("claude:cli", "claude:chat"),
Some((2, 1))
);
assert_eq!(
request_candidate_api_format_preference("claude:cli", "openai:chat"),
Some((3, 0))
);
}
#[test]
fn request_conversion_enable_flag_only_applies_to_real_data_format_conversions() {
assert!(!request_conversion_requires_enable_flag(
"claude:chat",
"claude:cli"
));
assert!(request_conversion_requires_enable_flag(
"openai:chat",
"openai:responses"
));
assert!(request_conversion_requires_enable_flag(
"claude:chat",
"gemini:chat"
));
assert!(request_conversion_requires_enable_flag(
"openai:compact",
"claude:cli"
));
}
}

View File

@@ -1,9 +1,6 @@
#![allow(dead_code)]
use aether_ai_formats::{
is_openai_responses_compact_format, legacy_openai_format_alias_matches,
normalize_legacy_openai_format_alias,
};
use aether_ai_formats::normalize_legacy_openai_format_alias;
use aether_provider_transport::auth::{
resolve_local_gemini_auth, resolve_local_openai_bearer_auth, resolve_local_standard_auth,
};
@@ -20,179 +17,62 @@ use aether_provider_transport::vertex::{
};
use aether_provider_transport::GatewayProviderTransportSnapshot;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestConversionKind {
ToOpenAIChat,
ToOpenAiResponses,
ToClaudeStandard,
ToGeminiStandard,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncChatResponseConversionKind {
ToOpenAIChat,
ToClaudeChat,
ToGeminiChat,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncCliResponseConversionKind {
ToOpenAiResponses,
ToClaudeCli,
ToGeminiCli,
}
const NON_COMPACT_STANDARD_CANDIDATE_API_FORMATS: &[&str] = &[
"openai:chat",
"openai:responses",
"claude:chat",
"claude:cli",
"gemini:chat",
"gemini:cli",
];
const STANDARD_API_FAMILY_ORDER: &[&str] = &["openai", "claude", "gemini"];
pub use aether_ai_formats::matrix::{
RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
};
pub fn request_candidate_api_format_preference(
client_api_format: &str,
provider_api_format: &str,
) -> Option<(u8, u8)> {
let client_api_format = normalize_legacy_openai_format_alias(client_api_format);
let provider_api_format = normalize_legacy_openai_format_alias(provider_api_format);
if client_api_format == "openai:responses:compact" {
return (provider_api_format == "openai:responses:compact").then_some((0, 0));
}
let (client_family, client_kind) =
parse_non_compact_standard_api_format(client_api_format.as_str())?;
let (provider_family, provider_kind) =
parse_non_compact_standard_api_format(provider_api_format.as_str())?;
let preference_bucket = if client_api_format == provider_api_format {
0
} else if client_kind == provider_kind {
1
} else if client_family == provider_family {
2
} else {
3
};
Some((
preference_bucket,
standard_api_family_priority(provider_family),
))
aether_ai_formats::matrix::request_candidate_api_format_preference(
client_api_format,
provider_api_format,
)
}
pub fn request_candidate_api_formats(
client_api_format: &str,
_require_streaming: bool,
require_streaming: bool,
) -> Vec<&'static str> {
let client_api_format = normalize_legacy_openai_format_alias(client_api_format);
if client_api_format == "openai:responses:compact" {
return vec!["openai:responses:compact"];
}
if parse_non_compact_standard_api_format(client_api_format.as_str()).is_none() {
return Vec::new();
}
let mut candidate_api_formats = NON_COMPACT_STANDARD_CANDIDATE_API_FORMATS.to_vec();
candidate_api_formats.sort_by_key(|provider_api_format| {
request_candidate_api_format_preference(client_api_format.as_str(), provider_api_format)
.unwrap_or((u8::MAX, u8::MAX))
});
candidate_api_formats
aether_ai_formats::matrix::request_candidate_api_formats(client_api_format, require_streaming)
}
pub fn request_conversion_kind(
client_api_format: &str,
provider_api_format: &str,
) -> Option<RequestConversionKind> {
let client_api_format = normalize_legacy_openai_format_alias(client_api_format);
let provider_api_format = normalize_legacy_openai_format_alias(provider_api_format);
if client_api_format == provider_api_format {
return None;
}
if !is_standard_api_format(client_api_format.as_str())
|| !is_standard_api_format(provider_api_format.as_str())
{
return None;
}
if is_openai_responses_compact_format(client_api_format.as_str())
|| is_openai_responses_compact_format(provider_api_format.as_str())
{
return None;
}
match provider_api_format.as_str() {
"openai:chat" => Some(RequestConversionKind::ToOpenAIChat),
"openai:responses" => Some(RequestConversionKind::ToOpenAiResponses),
"claude:chat" | "claude:cli" => Some(RequestConversionKind::ToClaudeStandard),
"gemini:chat" | "gemini:cli" => Some(RequestConversionKind::ToGeminiStandard),
_ => None,
}
aether_ai_formats::matrix::request_conversion_kind(client_api_format, provider_api_format)
}
pub fn sync_chat_response_conversion_kind(
provider_api_format: &str,
client_api_format: &str,
) -> Option<SyncChatResponseConversionKind> {
let provider_api_format = normalize_legacy_openai_format_alias(provider_api_format);
let client_api_format = normalize_legacy_openai_format_alias(client_api_format);
if provider_api_format == client_api_format {
return None;
}
if !is_standard_api_format(provider_api_format.as_str()) {
return None;
}
request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str())?;
match client_api_format.as_str() {
"openai:chat" => Some(SyncChatResponseConversionKind::ToOpenAIChat),
"claude:chat" => Some(SyncChatResponseConversionKind::ToClaudeChat),
"gemini:chat" => Some(SyncChatResponseConversionKind::ToGeminiChat),
_ => None,
}
aether_ai_formats::matrix::sync_chat_response_conversion_kind(
provider_api_format,
client_api_format,
)
}
pub fn sync_cli_response_conversion_kind(
provider_api_format: &str,
client_api_format: &str,
) -> Option<SyncCliResponseConversionKind> {
let provider_api_format = normalize_legacy_openai_format_alias(provider_api_format);
let client_api_format = normalize_legacy_openai_format_alias(client_api_format);
if provider_api_format == client_api_format {
return None;
}
if !is_standard_api_format(provider_api_format.as_str()) {
return None;
}
if !is_openai_responses_compact_format(client_api_format.as_str()) {
request_conversion_kind(client_api_format.as_str(), provider_api_format.as_str())?;
}
match client_api_format.as_str() {
"openai:responses" | "openai:responses:compact" => {
Some(SyncCliResponseConversionKind::ToOpenAiResponses)
}
"claude:cli" => Some(SyncCliResponseConversionKind::ToClaudeCli),
"gemini:cli" => Some(SyncCliResponseConversionKind::ToGeminiCli),
_ => None,
}
aether_ai_formats::matrix::sync_cli_response_conversion_kind(
provider_api_format,
client_api_format,
)
}
pub fn request_conversion_requires_enable_flag(
client_api_format: &str,
provider_api_format: &str,
) -> bool {
let client_api_format = normalize_legacy_openai_format_alias(client_api_format);
let provider_api_format = normalize_legacy_openai_format_alias(provider_api_format);
match (
api_data_format_id(client_api_format.as_str()),
api_data_format_id(provider_api_format.as_str()),
) {
(Some(client_data_format), Some(provider_data_format)) => {
client_data_format != provider_data_format
}
_ => true,
}
aether_ai_formats::matrix::request_conversion_requires_enable_flag(
client_api_format,
provider_api_format,
)
}
pub fn request_conversion_enabled_for_transport(
@@ -325,52 +205,6 @@ pub fn request_conversion_direct_auth(
}
}
fn is_standard_api_format(api_format: &str) -> bool {
matches!(
normalize_legacy_openai_format_alias(api_format).as_str(),
"openai:chat"
| "openai:responses"
| "openai:responses:compact"
| "claude:chat"
| "claude:cli"
| "gemini:chat"
| "gemini:cli"
)
}
fn parse_non_compact_standard_api_format(api_format: &str) -> Option<(&'static str, &'static str)> {
match normalize_legacy_openai_format_alias(api_format).as_str() {
"openai:chat" => Some(("openai", "chat")),
"openai:responses" => Some(("openai", "cli")),
"claude:chat" => Some(("claude", "chat")),
"claude:cli" => Some(("claude", "cli")),
"gemini:chat" => Some(("gemini", "chat")),
"gemini:cli" => Some(("gemini", "cli")),
_ => None,
}
}
fn standard_api_family_priority(family: &str) -> u8 {
STANDARD_API_FAMILY_ORDER
.iter()
.position(|candidate| *candidate == family)
.unwrap_or(STANDARD_API_FAMILY_ORDER.len()) as u8
}
fn api_data_format_id(api_format: &str) -> Option<&'static str> {
match normalize_legacy_openai_format_alias(api_format).as_str() {
"claude:chat" | "claude:cli" => Some("claude"),
"gemini:chat" | "gemini:cli" => Some("gemini"),
"openai:chat" => Some("openai_chat"),
"openai:responses" | "openai:responses:compact" => Some("openai_responses"),
_ => None,
}
}
fn normalized_same_standard_api_format(left: &str, right: &str) -> bool {
legacy_openai_format_alias_matches(left, right)
}
fn endpoint_accepts_client_api_format(
transport: &GatewayProviderTransportSnapshot,
client_api_format: &str,