Merge branch 'fawney19:main' into main

This commit is contained in:
ZheFox
2026-06-16 12:17:28 +08:00
committed by GitHub
160 changed files with 24102 additions and 1311 deletions
+9 -6
View File
@@ -251,16 +251,19 @@ pub use aether_ai_formats::{
canonical_to_openai_chat_response, canonical_to_openai_responses_compact_request,
canonical_to_openai_responses_compact_response, canonical_to_openai_responses_request,
canonical_to_openai_responses_response, canonical_unknown_block_count, convert_request,
convert_response, 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,
convert_request_pure, convert_request_pure_with_context, convert_response,
convert_response_pure, emit_request_pure, emit_response_pure, 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, parse_request_pure, parse_response_pure,
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, CanonicalContentBlock,
CanonicalGenerationConfig, CanonicalInstruction, CanonicalMessage, CanonicalRequest,
CanonicalResponse, CanonicalResponseFormat, CanonicalResponseOutput, CanonicalRole,
CanonicalStopReason, CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition,
CanonicalUsage, FormatContext, FormatError, FormatFamily, FormatId, FormatProfile,
RequestConversionKind, SyncChatResponseConversionKind, SyncCliResponseConversionKind,
CanonicalUsage, ConversionFieldRecord, ConversionFieldStatus, ConversionReport, Converted,
FormatContext, FormatError, FormatFamily, FormatId, FormatProfile, RequestConversionKind,
SyncChatResponseConversionKind, SyncCliResponseConversionKind,
};
@@ -1,11 +1,58 @@
use serde_json::{Map, Value};
use crate::formats::context::FormatContext;
use crate::formats::openai::embedding::request::mapped_embedding_model;
use crate::formats::openai::embedding::request::{mapped_embedding_model, namespace_extensions};
use crate::protocol::canonical::{
CanonicalEmbeddingContent, CanonicalEmbeddingInput, CanonicalRequest,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
from_raw(body)
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
let request = body_json.as_object()?;
let model = request
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let contents = request
.get("input")
.and_then(Value::as_object)
.and_then(|input| input.get("contents"))
.and_then(Value::as_array)?;
let input = contents_to_embedding_input(contents)?;
let mut parameters = request
.get("parameters")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let dimensions = parameters
.remove("dimension")
.or_else(|| request.get("dimensions").cloned())
.and_then(|value| value.as_u64());
let parameters = (!parameters.is_empty()).then_some(parameters);
Some(CanonicalRequest {
model,
embedding: Some(crate::protocol::canonical::CanonicalEmbeddingRequest {
input,
encoding_format: None,
dimensions,
task: None,
user: None,
parameters,
extensions: namespace_extensions(
"aliyun",
request,
&["model", "input", "parameters", "dimensions"],
),
}),
..CanonicalRequest::default()
})
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
let embedding = request.embedding.as_ref()?;
let contents = embedding_input_to_contents(&embedding.input)?;
@@ -42,6 +89,67 @@ pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
Some(Value::Object(output))
}
fn contents_to_embedding_input(contents: &[Value]) -> Option<CanonicalEmbeddingInput> {
if contents.is_empty() {
return None;
}
let parsed = contents
.iter()
.map(embedding_content_from_value)
.collect::<Option<Vec<_>>>()?;
if parsed.iter().all(|content| {
content.image.is_none() && content.video.is_none() && content.multi_images.is_none()
}) {
return Some(CanonicalEmbeddingInput::StringArray(
parsed
.into_iter()
.map(|content| content.text)
.collect::<Option<Vec<_>>>()?,
));
}
Some(CanonicalEmbeddingInput::Multimodal(parsed))
}
fn embedding_content_from_value(value: &Value) -> Option<CanonicalEmbeddingContent> {
let object = value.as_object()?;
let content = CanonicalEmbeddingContent {
text: object
.get("text")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
image: object
.get("image")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
video: object
.get("video")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
multi_images: match object.get("multi_images").and_then(Value::as_array) {
Some(values) => Some(
values
.iter()
.map(|value| {
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.collect::<Option<Vec<_>>>()?,
),
None => None,
},
};
(!content.is_empty()).then_some(content)
}
fn embedding_input_to_contents(input: &CanonicalEmbeddingInput) -> Option<Vec<Value>> {
match input {
CanonicalEmbeddingInput::String(text) => {
@@ -5,7 +5,8 @@ use serde_json::{json, Value};
use crate::{
formats::context::FormatContext,
protocol::canonical::{
canonical_blocks_to_claude, canonical_stop_reason_to_claude, canonical_usage_to_claude,
canonical_blocks_to_claude, canonical_extension_object_mut,
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,
@@ -28,6 +29,27 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
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));
let mut extensions = claude_extensions(
body,
&[
"id",
"type",
"role",
"model",
"content",
"stop_reason",
"stop_sequence",
"usage",
],
);
if let Some(raw_stop_reason) = body.get("stop_reason").cloned() {
canonical_extension_object_mut(&mut extensions, "claude")
.insert("raw_stop_reason".to_string(), raw_stop_reason);
}
if let Some(raw_stop_sequence) = body.get("stop_sequence").cloned() {
canonical_extension_object_mut(&mut extensions, "claude")
.insert("raw_stop_sequence".to_string(), raw_stop_sequence);
}
Some(CanonicalResponse {
id: body
.get("id")
@@ -49,19 +71,7 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
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",
],
),
extensions,
})
}
@@ -86,12 +96,23 @@ pub fn to_raw(canonical: &CanonicalResponse) -> Value {
"output_tokens": 0,
})),
});
if let Some(claude) = canonical
.extensions
.get("claude")
.and_then(Value::as_object)
{
if let Some(raw_stop_reason) = claude.get("raw_stop_reason").cloned() {
response["stop_reason"] = raw_stop_reason;
}
if let Some(raw_stop_sequence) = claude.get("raw_stop_sequence").cloned() {
response["stop_sequence"] = raw_stop_sequence;
}
}
if let Some(object) = response.as_object_mut() {
object.extend(namespace_extension_object(
&canonical.extensions,
"claude",
object,
));
let mut extra = namespace_extension_object(&canonical.extensions, "claude", object);
extra.remove("raw_stop_reason");
extra.remove("raw_stop_sequence");
object.extend(extra);
}
response
}
@@ -322,8 +322,7 @@ impl ClaudeProviderState {
let finish_reason = map_claude_stop_reason(
delta.get("stop_reason").and_then(Value::as_str),
delta.get("stop_reason").and_then(Value::as_str) == Some("tool_use"),
)
.map(ToOwned::to_owned);
);
let (id, model) = self.identity(report_context);
out.push(CanonicalStreamFrame {
id,
+160 -4
View File
@@ -1,5 +1,6 @@
use std::{error::Error, fmt};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Debug, Clone, Default)]
@@ -31,6 +32,15 @@ impl FormatContext {
self
}
pub fn without_runtime_request_edits(&self) -> Self {
Self {
mapped_model: None,
request_path: self.request_path.clone(),
upstream_is_stream: false,
report_context: self.report_context.clone(),
}
}
pub(crate) fn mapped_model_or<'a>(&'a self, fallback: &'a str) -> &'a str {
self.mapped_model
.as_deref()
@@ -47,13 +57,116 @@ impl FormatContext {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ConversionFieldStatus {
Native,
Mapped,
ExtensionPreserved,
Unaudited,
Unsupported,
InvalidEnum,
LossyBlocked,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConversionFieldRecord {
pub field: String,
pub status: ConversionFieldStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
impl ConversionFieldRecord {
pub fn new(
field: impl Into<String>,
status: ConversionFieldStatus,
detail: Option<String>,
) -> Self {
Self {
field: field.into(),
status,
detail,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ConversionReport {
pub source_format: String,
pub target_format: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fields: Vec<ConversionFieldRecord>,
}
impl ConversionReport {
pub fn new(source_format: impl Into<String>, target_format: impl Into<String>) -> Self {
Self {
source_format: source_format.into(),
target_format: target_format.into(),
fields: Vec::new(),
}
}
pub fn record(
&mut self,
field: impl Into<String>,
status: ConversionFieldStatus,
detail: Option<String>,
) {
self.fields
.push(ConversionFieldRecord::new(field, status, detail));
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Converted<T> {
pub value: T,
pub report: ConversionReport,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormatError {
UnsupportedFormat(String),
RequestParseFailed { format: String },
RequestEmitFailed { format: String },
ResponseParseFailed { format: String },
ResponseEmitFailed { format: String },
RequestParseFailed {
format: String,
},
RequestEmitFailed {
format: String,
},
ResponseParseFailed {
format: String,
},
ResponseEmitFailed {
format: String,
},
UnsupportedField {
format: String,
field: String,
reason: String,
},
UnauditedField {
source_format: String,
target_format: String,
field: String,
reason: String,
},
InvalidEnumValue {
format: String,
field: String,
value: String,
},
LossyConversionBlocked {
source_format: String,
target_format: String,
field: String,
reason: String,
},
InvalidTargetField {
format: String,
field: String,
reason: String,
},
}
impl fmt::Display for FormatError {
@@ -70,6 +183,49 @@ impl fmt::Display for FormatError {
Self::ResponseEmitFailed { format } => {
write!(f, "failed to emit {format} response")
}
Self::UnsupportedField {
format,
field,
reason,
} => {
write!(f, "unsupported field {field} in {format}: {reason}")
}
Self::UnauditedField {
source_format,
target_format,
field,
reason,
} => {
write!(
f,
"unaudited field {field} in {source_format} cannot be converted to {target_format}: {reason}"
)
}
Self::InvalidEnumValue {
format,
field,
value,
} => {
write!(f, "invalid enum value {value:?} for {format}.{field}")
}
Self::LossyConversionBlocked {
source_format,
target_format,
field,
reason,
} => {
write!(
f,
"lossy conversion blocked from {source_format} to {target_format} at {field}: {reason}"
)
}
Self::InvalidTargetField {
format,
field,
reason,
} => {
write!(f, "invalid target field {field} for {format}: {reason}")
}
}
}
}
@@ -723,15 +723,13 @@ mod tests {
"file": {"file_data": "data:application/pdf;base64,JVBERi0x"}
}),
json!({"type": "text", "text": "[File: https://example.com/report.pdf]"}),
json!({
"type": "text",
"text": "[Claude tool_result document content omitted: text/plain]"
}),
json!({"type": "text", "text": "document body"}),
]
);
let block_content_json = Value::Array(block_content.clone()).to_string();
assert!(!block_content_json.contains("\"source\""));
assert!(!block_content_json.contains("document body"));
assert!(block_content_json.contains("document body"));
assert!(!block_content_json.contains("content omitted"));
}
#[test]
@@ -960,4 +958,90 @@ mod tests {
"data:image/png;base64,AAAA"
);
}
#[test]
fn claude_request_to_responses_rejects_unrepresentable_tool_result_blocks() {
let body = 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"
}
}]
}]
}],
"max_tokens": 128,
});
let error = registry::convert_request(
"claude:messages",
"openai:responses",
&body,
&FormatContext::default(),
)
.expect_err("unrepresentable Claude tool_result block should fail closed");
assert!(matches!(
error,
registry::FormatError::LossyConversionBlocked {
ref source_format,
ref target_format,
ref field,
..
} if source_format == "claude:messages"
&& target_format == "openai:responses"
&& field == "messages[].content[].tool_result.content"
));
}
#[test]
fn claude_request_to_openai_chat_rejects_unrepresentable_tool_result_blocks() {
let body = 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"
}
}]
}]
}],
"max_tokens": 128,
});
let error = registry::convert_request(
"claude:messages",
"openai:chat",
&body,
&FormatContext::default(),
)
.expect_err("unrepresentable Claude tool_result block should fail closed for Chat");
assert!(matches!(
error,
registry::FormatError::LossyConversionBlocked {
ref source_format,
ref target_format,
ref field,
..
} if source_format == "claude:messages"
&& target_format == "openai:chat"
&& field == "messages[].content[].tool_result.content"
));
}
}
@@ -5,6 +5,10 @@ use crate::formats::context::FormatContext;
use crate::formats::openai::embedding::request::mapped_embedding_model;
use crate::protocol::canonical::{namespace_extension_object, CanonicalRequest};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
crate::formats::openai::embedding::request::from_namespace(body, "doubao")
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
let embedding = request.embedding.as_ref()?;
let items = embedding.input.as_string_items()?;
@@ -1,8 +1,137 @@
use serde_json::{json, Map, Value};
use crate::formats::context::FormatContext;
use crate::formats::openai::embedding::request::mapped_embedding_model;
use crate::protocol::canonical::{CanonicalEmbeddingRequest, CanonicalRequest};
use crate::formats::openai::embedding::request::{mapped_embedding_model, namespace_extensions};
use crate::protocol::canonical::{
CanonicalEmbeddingInput, CanonicalEmbeddingRequest, CanonicalRequest,
};
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
from_raw(body)
}
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
let request = body_json.as_object()?;
if let Some(requests) = request.get("requests").and_then(Value::as_array) {
return from_batch_requests(request, requests);
}
let item = parse_gemini_embedding_request_object(request)?;
Some(CanonicalRequest {
model: item.model,
embedding: Some(CanonicalEmbeddingRequest {
input: CanonicalEmbeddingInput::String(item.text),
encoding_format: None,
dimensions: item.dimensions,
task: item.task,
user: None,
parameters: None,
extensions: namespace_extensions(
"gemini",
request,
&[
"model",
"content",
"outputDimensionality",
"output_dimensionality",
"taskType",
"task_type",
],
),
}),
..CanonicalRequest::default()
})
}
fn from_batch_requests(
request: &Map<String, Value>,
requests: &[Value],
) -> Option<CanonicalRequest> {
if requests.is_empty() {
return None;
}
let items = requests
.iter()
.map(|request| parse_gemini_embedding_request_object(request.as_object()?))
.collect::<Option<Vec<_>>>()?;
let first = items.first()?;
if items.iter().any(|item| {
item.model != first.model || item.dimensions != first.dimensions || item.task != first.task
}) {
return None;
}
let model = first.model.clone();
let dimensions = first.dimensions;
let task = first.task.clone();
Some(CanonicalRequest {
model,
embedding: Some(CanonicalEmbeddingRequest {
input: CanonicalEmbeddingInput::StringArray(
items.into_iter().map(|item| item.text).collect(),
),
encoding_format: None,
dimensions,
task,
user: None,
parameters: None,
extensions: namespace_extensions("gemini", request, &["requests"]),
}),
..CanonicalRequest::default()
})
}
struct ParsedGeminiEmbeddingRequest {
model: String,
text: String,
dimensions: Option<u64>,
task: Option<String>,
}
fn parse_gemini_embedding_request_object(
request: &Map<String, Value>,
) -> Option<ParsedGeminiEmbeddingRequest> {
let model = request
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?
.to_string();
let parts = request
.get("content")
.and_then(Value::as_object)
.and_then(|content| content.get("parts"))
.and_then(Value::as_array)?;
let text = parts
.iter()
.map(|part| {
part.as_object()?
.get("text")?
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
.collect::<Option<Vec<_>>>()?
.join("\n");
if text.trim().is_empty() {
return None;
}
Some(ParsedGeminiEmbeddingRequest {
model,
text,
dimensions: request
.get("outputDimensionality")
.or_else(|| request.get("output_dimensionality"))
.and_then(Value::as_u64),
task: request
.get("taskType")
.or_else(|| request.get("task_type"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned),
})
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
let embedding = request.embedding.as_ref()?;
@@ -15,7 +15,7 @@ use crate::{
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_google_search_grounding, gemini_openai_extra_body,
gemini_generation_config_extra, gemini_google_search_grounding,
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,
@@ -173,10 +173,6 @@ pub fn from_raw(body_json: &Value, request_path: &str) -> Option<CanonicalReques
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);
@@ -352,6 +348,7 @@ fn canonical_block_to_gemini_part(
..
} => 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()),
@@ -676,12 +673,21 @@ fn canonical_tool_to_gemini_declaration(tool: &CanonicalToolDefinition) -> Value
Value::String(description.clone()),
);
}
let raw_parameters = tool
.extensions
.get("gemini")
.and_then(Value::as_object)
.and_then(|value| value.get("raw_parameters"))
.cloned();
declaration.insert(
"parameters".to_string(),
tool.parameters
raw_parameters
.clone()
.or_else(|| tool.parameters.clone())
.map(|mut schema| {
clean_gemini_schema(&mut schema);
if raw_parameters.is_none() {
clean_gemini_schema(&mut schema);
}
schema
})
.unwrap_or_else(|| json!({})),
@@ -808,7 +814,7 @@ mod tests {
use crate::CanonicalContentBlock;
#[test]
fn canonical_tool_result_to_gemini_request_omits_function_response_id() {
fn canonical_tool_result_to_gemini_request_preserves_function_response_id() {
let mut tool_name_by_id = BTreeMap::new();
tool_name_by_id.insert("call_1".to_string(), "lookup".to_string());
@@ -831,7 +837,7 @@ mod tests {
.and_then(Value::as_object)
.expect("functionResponse should exist");
assert!(!function_response.contains_key("id"));
assert_eq!(function_response["id"], "call_1");
assert_eq!(function_response["name"], "lookup");
assert_eq!(
function_response["response"],
@@ -55,6 +55,18 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
{
stop_reason = Some(CanonicalStopReason::ToolUse);
}
let mut extensions = gemini_extensions(
candidate_object,
&["index", "content", "finishReason", "finish_reason"],
);
if let Some(raw_finish_reason) = candidate_object
.get("finishReason")
.or_else(|| candidate_object.get("finish_reason"))
.cloned()
{
canonical_extension_object_mut(&mut extensions, "gemini")
.insert("raw_finish_reason".to_string(), raw_finish_reason);
}
outputs.push(CanonicalResponseOutput {
index: candidate_object
.get("index")
@@ -64,10 +76,7 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
role: CanonicalRole::Assistant,
content,
stop_reason,
extensions: gemini_extensions(
candidate_object,
&["index", "content", "finishReason", "finish_reason"],
),
extensions,
});
}
outputs.retain(gemini_response_output_has_visible_content);
@@ -177,7 +186,13 @@ fn canonical_to_gemini_response(
});
if let Some(candidate_object) = candidate.as_object_mut() {
if let Some(gemini) = output.extensions.get("gemini").and_then(Value::as_object) {
if let Some(raw_finish_reason) = gemini.get("raw_finish_reason").cloned() {
candidate_object.insert("finishReason".to_string(), raw_finish_reason);
}
for (key, value) in gemini {
if key == "raw_finish_reason" {
continue;
}
candidate_object.entry(key.clone()).or_insert(value.clone());
}
}
@@ -298,13 +298,8 @@ impl GeminiProviderState {
candidate_object.get("finishReason").and_then(Value::as_str)
{
let has_tool_calls = !self.tool_calls.is_empty();
let mut finish_reason = normalize_openai_finish_reason(match finish_reason {
"STOP" => Some("stop"),
"MAX_TOKENS" => Some("length"),
"SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII"
| "OTHER" => Some("content_filter"),
other => Some(other),
});
let mut finish_reason =
normalize_openai_finish_reason(map_gemini_stream_finish_reason(finish_reason));
if has_tool_calls && finish_reason.as_deref().is_none_or(|value| value == "stop") {
finish_reason = Some("tool_calls".to_string());
}
@@ -343,6 +338,23 @@ impl GeminiProviderState {
}
}
fn map_gemini_stream_finish_reason(value: &str) -> Option<&str> {
match value {
"STOP" => Some("stop"),
"MAX_TOKENS" => Some("length"),
"SAFETY"
| "RECITATION"
| "LANGUAGE"
| "BLOCKLIST"
| "PROHIBITED_CONTENT"
| "SPII"
| "IMAGE_SAFETY"
| "IMAGE_PROHIBITED_CONTENT"
| "IMAGE_RECITATION" => Some("content_filter"),
other => Some(other),
}
}
#[derive(Default)]
struct GeminiClientToolState {
call_id: String,
@@ -5,10 +5,11 @@ use crate::{
protocol::canonical::{
canonical_extension_object_mut, canonical_message_to_openai_chat_messages,
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,
canonical_tool_to_openai, is_claude_tool_result, 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, CanonicalContentBlock,
CanonicalInstruction, CanonicalRequest, CanonicalRole, CanonicalThinkingConfig,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
@@ -19,6 +20,9 @@ pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
}
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
if canonical_request_has_unrepresentable_claude_tool_result_for_openai_chat(request) {
return None;
}
let mut body = to_raw(request);
force_stream_options(&mut body, ctx.upstream_is_stream);
Some(body)
@@ -103,7 +107,6 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
"top_p",
"top_k",
"stop",
"stream",
"tools",
"tool_choice",
"parallel_tool_calls",
@@ -220,6 +223,94 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value {
Value::Object(output)
}
fn canonical_request_has_unrepresentable_claude_tool_result_for_openai_chat(
request: &CanonicalRequest,
) -> bool {
request.messages.iter().any(|message| {
message.content.iter().any(|block| {
let CanonicalContentBlock::ToolResult {
output, extensions, ..
} = block
else {
return false;
};
is_claude_tool_result(extensions)
&& output
.as_ref()
.and_then(Value::as_array)
.is_some_and(|parts| {
!claude_tool_result_parts_are_openai_chat_representable(parts)
})
})
})
}
pub(crate) fn claude_tool_result_parts_are_openai_chat_representable(parts: &[Value]) -> bool {
parts
.iter()
.all(claude_tool_result_part_is_openai_chat_representable)
}
fn claude_tool_result_part_is_openai_chat_representable(part: &Value) -> bool {
let Some(part_object) = part.as_object() else {
return false;
};
match part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"text" => true,
"image" => claude_image_block_is_openai_chat_representable(part_object),
"document" | "file" => claude_document_block_is_openai_chat_representable(part_object),
_ => false,
}
}
fn claude_image_block_is_openai_chat_representable(block: &Map<String, Value>) -> bool {
let Some(source) = block.get("source").and_then(Value::as_object) else {
return false;
};
match source
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"base64" => {
non_empty_source_str(source, "media_type").is_some()
&& non_empty_source_str(source, "data").is_some()
}
"url" => non_empty_source_str(source, "url").is_some(),
_ => false,
}
}
fn claude_document_block_is_openai_chat_representable(block: &Map<String, Value>) -> bool {
let Some(source) = block.get("source").and_then(Value::as_object) else {
return false;
};
match source
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"base64" => {
non_empty_source_str(source, "media_type").is_some()
&& non_empty_source_str(source, "data").is_some()
}
"url" => non_empty_source_str(source, "url").is_some(),
"text" => non_empty_source_str(source, "data").is_some(),
_ => false,
}
}
fn non_empty_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())
}
fn openai_chat_reasoning_effort(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"low" => Some("low"),
@@ -70,6 +70,13 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
}
let stop_reason =
openai_finish_reason_to_canonical(choice.get("finish_reason").and_then(Value::as_str));
let mut extensions = BTreeMap::new();
if let Some(raw_finish_reason) = choice.get("finish_reason").cloned() {
extensions.insert(
"openai".to_string(),
json!({ "raw_finish_reason": raw_finish_reason }),
);
}
outputs.push(CanonicalResponseOutput {
index: choice
.get("index")
@@ -79,7 +86,7 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
role: CanonicalRole::Assistant,
content,
stop_reason,
extensions: BTreeMap::new(),
extensions,
});
}
let first_output = outputs.first()?;
@@ -123,10 +130,21 @@ pub fn to_raw(canonical: &CanonicalResponse) -> Value {
.iter()
.enumerate()
.map(|(fallback_index, output)| {
let finish_reason = output
.extensions
.get("openai")
.and_then(Value::as_object)
.and_then(|openai| openai.get("raw_finish_reason"))
.cloned()
.unwrap_or_else(|| {
Value::String(
canonical_stop_reason_to_openai(output.stop_reason.as_ref()).to_string(),
)
});
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()),
"finish_reason": finish_reason,
})
.as_object()
.map(|choice| {
@@ -683,6 +683,136 @@ impl OpenAIResponsesProviderState {
self.emit_ready_tool_call(report_context, out, index);
}
fn emit_custom_tool_call_item(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
item: &Map<String, Value>,
output_index: Option<usize>,
) {
if item.get("type").and_then(Value::as_str) != Some("custom_tool_call") {
return;
}
let name = item
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("custom_tool")
.to_string();
let arguments = tool_arguments_from_maybe_json_string(
item.get("input").or_else(|| item.get("arguments")),
"input",
);
self.emit_generic_tool_call_item(report_context, out, item, output_index, name, arguments);
}
fn emit_shell_tool_call_item(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
item: &Map<String, Value>,
output_index: Option<usize>,
) {
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
let name = match item_type {
"local_shell_call" => "local_shell",
"shell_call" => "shell",
_ => return,
};
let arguments = tool_arguments_from_named_fields(
item,
&[
"action",
"environment",
"status",
"created_by",
"max_output_length",
],
);
self.emit_generic_tool_call_item(
report_context,
out,
item,
output_index,
name.to_string(),
arguments,
);
}
fn emit_apply_patch_tool_call_item(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
item: &Map<String, Value>,
output_index: Option<usize>,
) {
if item.get("type").and_then(Value::as_str) != Some("apply_patch_call") {
return;
}
let arguments = tool_arguments_from_named_fields(item, &["operation", "status"]);
self.emit_generic_tool_call_item(
report_context,
out,
item,
output_index,
"apply_patch".to_string(),
arguments,
);
}
fn emit_computer_tool_call_item(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
item: &Map<String, Value>,
output_index: Option<usize>,
) {
if item.get("type").and_then(Value::as_str) != Some("computer_call") {
return;
}
let arguments = tool_arguments_from_named_fields(
item,
&["action", "actions", "pending_safety_checks", "status"],
);
self.emit_generic_tool_call_item(
report_context,
out,
item,
output_index,
"computer".to_string(),
arguments,
);
}
fn emit_generic_tool_call_item(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
item: &Map<String, Value>,
output_index: Option<usize>,
name: String,
arguments: String,
) {
self.ensure_started(report_context, out);
let key = item
.get("call_id")
.or_else(|| item.get("id"))
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let index = self.tool_index_for_key(key, output_index);
let state = self.tool_calls.entry(index).or_default();
state.call_id = item
.get("call_id")
.or_else(|| item.get("id"))
.and_then(Value::as_str)
.unwrap_or(state.call_id.as_str())
.to_string();
state.name = name;
Self::merge_tool_call_arguments(state, &arguments);
self.emit_ready_tool_call(report_context, out, index);
}
fn emit_missing_tool_result(
&mut self,
report_context: &Value,
@@ -756,6 +886,54 @@ impl OpenAIResponsesProviderState {
self.emit_missing_tool_result(report_context, out, index, tool_use_id, name, &content);
}
fn emit_generic_tool_result_item(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
item: &Map<String, Value>,
output_index: Option<usize>,
) {
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
let is_supported_result = matches!(
item_type,
"custom_tool_call_output"
| "local_shell_call_output"
| "shell_call_output"
| "apply_patch_call_output"
| "computer_call_output"
);
if !is_supported_result {
return;
}
let tool_use_id = item
.get("call_id")
.or_else(|| item.get("tool_call_id"))
.or_else(|| item.get("id"))
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or("call_auto_0")
.to_string();
let index =
self.tool_index_for_key(Some(format!("{item_type}:{tool_use_id}")), output_index);
let content = openai_tool_result_content_from_value(
item.get("output")
.or_else(|| item.get("content"))
.or_else(|| item.get("delta")),
);
let name = match item_type {
"local_shell_call_output" => Some("local_shell".to_string()),
"shell_call_output" => Some("shell".to_string()),
"apply_patch_call_output" => Some("apply_patch".to_string()),
"computer_call_output" => Some("computer".to_string()),
_ => item
.get("name")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned),
};
self.emit_missing_tool_result(report_context, out, index, tool_use_id, name, &content);
}
fn emit_message_item(
&mut self,
report_context: &Value,
@@ -870,6 +1048,79 @@ impl OpenAIResponsesProviderState {
});
}
fn emit_output_item(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
item: &Map<String, Value>,
output_index: Option<usize>,
final_item: bool,
) {
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
"function_call" => self.emit_tool_call_item(report_context, out, item, output_index),
"function_call_output" => {
self.emit_tool_result_item(report_context, out, item, output_index);
}
"custom_tool_call" => {
self.emit_custom_tool_call_item(report_context, out, item, output_index);
}
"local_shell_call" | "shell_call" => {
self.emit_shell_tool_call_item(report_context, out, item, output_index);
}
"apply_patch_call" => {
self.emit_apply_patch_tool_call_item(report_context, out, item, output_index);
}
"computer_call" => {
self.emit_computer_tool_call_item(report_context, out, item, output_index);
}
"custom_tool_call_output"
| "local_shell_call_output"
| "shell_call_output"
| "apply_patch_call_output"
| "computer_call_output" => {
self.emit_generic_tool_result_item(report_context, out, item, output_index);
}
"message" => self.emit_message_item(report_context, out, item, output_index),
"reasoning" if final_item => self.emit_reasoning_item(report_context, out, item),
"reasoning" => self.ensure_started(report_context, out),
"image_generation_call" => {
self.emit_image_generation_item(
report_context,
out,
item,
output_index,
final_item,
);
}
"web_search_call" | "file_search_call" | "code_interpreter_call" | "mcp_call" => {
if !final_item {
self.ensure_started(report_context, out);
}
}
_ => out.push(self.unknown_frame(report_context, Value::Object(item.clone()))),
}
}
fn emit_response_output_items(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
response: &Map<String, Value>,
) {
for (output_index, raw_item) in response
.get("output")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
{
let Some(item) = raw_item.as_object() else {
continue;
};
self.emit_output_item(report_context, out, item, Some(output_index), true);
}
}
pub fn push_line(
&mut self,
report_context: &Value,
@@ -988,6 +1239,27 @@ impl OpenAIResponsesProviderState {
self.emit_missing_text(report_context, &mut out, key, refusal);
}
}
"response.audio.transcript.delta" => {
let piece = value
.get("delta")
.and_then(Value::as_str)
.unwrap_or_default();
if !piece.is_empty() {
let key = Self::text_part_key_from_event(&value);
self.emit_text_delta(report_context, &mut out, key, piece);
}
}
"response.audio.transcript.done" => {
let transcript = value
.get("transcript")
.and_then(Value::as_str)
.or_else(|| value.get("text").and_then(Value::as_str))
.unwrap_or_default();
if !transcript.is_empty() {
let key = Self::text_part_key_from_event(&value);
self.emit_missing_text(report_context, &mut out, key, transcript);
}
}
"response.reasoning_text.delta" | "response.reasoning_summary_text.delta" => {
let piece = value
.get("delta")
@@ -1054,32 +1326,70 @@ impl OpenAIResponsesProviderState {
.get("output_index")
.and_then(Value::as_u64)
.map(|value| value as usize);
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
"function_call" => {
self.emit_tool_call_item(report_context, &mut out, item, output_index);
}
"function_call_output" => {
self.emit_tool_result_item(report_context, &mut out, item, output_index);
}
"message" => {
self.emit_message_item(report_context, &mut out, item, output_index);
}
"reasoning" => {
self.ensure_started(report_context, &mut out);
}
"image_generation_call" => {
self.emit_image_generation_item(
report_context,
&mut out,
item,
output_index,
false,
);
}
_ => {
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
}
self.emit_output_item(report_context, &mut out, item, output_index, false);
}
"response.custom_tool_call_input.delta" => {
let delta = value
.get("delta")
.and_then(Value::as_str)
.unwrap_or_default();
if delta.is_empty() {
return Ok(out);
}
self.ensure_started(report_context, &mut out);
let key = value
.get("item_id")
.or_else(|| value.get("call_id"))
.or_else(|| value.get("id"))
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let output_index = value
.get("output_index")
.and_then(Value::as_u64)
.map(|value| value as usize);
let index = self.tool_index_for_key(key, output_index);
let state = self.tool_calls.entry(index).or_default();
if state.name.is_empty() {
state.name = value
.get("name")
.and_then(Value::as_str)
.unwrap_or("custom_tool")
.to_string();
}
state.arguments.push_str(delta);
self.emit_ready_tool_call(report_context, &mut out, index);
}
"response.custom_tool_call_input.done" => {
let input = value
.get("input")
.and_then(Value::as_str)
.unwrap_or_default();
self.ensure_started(report_context, &mut out);
let key = value
.get("item_id")
.or_else(|| value.get("call_id"))
.or_else(|| value.get("id"))
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let output_index = value
.get("output_index")
.and_then(Value::as_u64)
.map(|value| value as usize);
let index = self.tool_index_for_key(key, output_index);
let state = self.tool_calls.entry(index).or_default();
if state.name.is_empty() {
state.name = value
.get("name")
.and_then(Value::as_str)
.unwrap_or("custom_tool")
.to_string();
}
let arguments = tool_arguments_from_maybe_json_string(
Some(&Value::String(input.to_string())),
"input",
);
Self::merge_tool_call_arguments(state, &arguments);
self.emit_ready_tool_call(report_context, &mut out, index);
}
"response.function_call_arguments.delta" => {
let delta = value
@@ -1226,32 +1536,28 @@ impl OpenAIResponsesProviderState {
.get("output_index")
.and_then(Value::as_u64)
.map(|value| value as usize);
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
"function_call" => {
self.emit_tool_call_item(report_context, &mut out, item, output_index);
}
"function_call_output" => {
self.emit_tool_result_item(report_context, &mut out, item, output_index);
}
"message" => {
self.emit_message_item(report_context, &mut out, item, output_index);
}
"reasoning" => {
self.emit_reasoning_item(report_context, &mut out, item);
}
"image_generation_call" => {
self.emit_image_generation_item(
report_context,
&mut out,
item,
output_index,
true,
);
}
_ => {
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
}
}
self.emit_output_item(report_context, &mut out, item, output_index, true);
}
"response.incomplete" => {
let Some(response) = value.get("response").and_then(Value::as_object) else {
return Ok(out);
};
self.ensure_started(report_context, &mut out);
let (id, model) = self.identity(report_context);
self.emit_response_output_items(report_context, &mut out, response);
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::Finish {
finish_reason: Some(openai_responses_incomplete_finish_reason(&value)),
usage: canonical_usage_from_openai_usage(response.get("usage")),
},
});
self.finished = true;
}
event_type if openai_responses_stream_event_is_known_noop(event_type) => {
self.ensure_started(report_context, &mut out);
}
event_type if openai_stream_payload_is_terminal_error(&value) => {
self.finished = true;
@@ -1276,61 +1582,7 @@ impl OpenAIResponsesProviderState {
};
self.ensure_started(report_context, &mut out);
let (id, model) = self.identity(report_context);
for (output_index, raw_item) in response
.get("output")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
{
let Some(item) = raw_item.as_object() else {
continue;
};
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
"message" => {
self.emit_message_item(
report_context,
&mut out,
item,
Some(output_index),
);
}
"function_call" => {
self.emit_tool_call_item(
report_context,
&mut out,
item,
Some(output_index),
);
}
"function_call_output" => {
self.emit_tool_result_item(
report_context,
&mut out,
item,
Some(output_index),
);
}
"reasoning" => {
self.emit_reasoning_item(report_context, &mut out, item);
}
"image_generation_call" => {
self.emit_image_generation_item(
report_context,
&mut out,
item,
Some(output_index),
true,
);
}
_ => {
out.push(
self.unknown_frame(report_context, Value::Object(item.clone())),
);
}
}
}
self.emit_response_output_items(report_context, &mut out, response);
let finish_reason = if self.tool_calls.is_empty() {
Some("stop".to_string())
@@ -2265,7 +2517,12 @@ impl OpenAIResponsesClientEmitter {
Ok(out)
}
fn completed_response(&self, usage: CanonicalUsage) -> Value {
fn terminal_response(
&self,
usage: CanonicalUsage,
status: &str,
incomplete_reason: Option<&str>,
) -> Value {
let mut ordered_output = Vec::new();
let summary = if self.reasoning_summary_parts.is_empty() {
if self.reasoning.trim().is_empty() {
@@ -2387,7 +2644,7 @@ impl OpenAIResponsesClientEmitter {
let mut response = json!({
"id": self.response_id(),
"object": "response",
"status": "completed",
"status": status,
"model": self.model(),
"output": ordered_output
.into_iter()
@@ -2395,6 +2652,9 @@ impl OpenAIResponsesClientEmitter {
.collect::<Vec<_>>(),
"usage": openai_responses_usage_from_usage(&usage),
});
if let Some(reason) = incomplete_reason {
response["incomplete_details"] = json!({ "reason": reason });
}
if let Some(response_object) = response.as_object_mut() {
if let Some(created_at) = self.created_at {
response_object.insert("created_at".to_string(), Value::from(created_at));
@@ -2404,6 +2664,14 @@ impl OpenAIResponsesClientEmitter {
response
}
fn completed_response(&self, usage: CanonicalUsage) -> Value {
self.terminal_response(usage, "completed", None)
}
fn incomplete_response(&self, usage: CanonicalUsage, reason: &str) -> Value {
self.terminal_response(usage, "incomplete", Some(reason))
}
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
self.update_identity(&frame);
match frame.event {
@@ -2674,7 +2942,10 @@ impl OpenAIResponsesClientEmitter {
self.encode_response_event(event.as_str(), payload)
}
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
CanonicalStreamEvent::Finish { usage, .. } => {
CanonicalStreamEvent::Finish {
finish_reason,
usage,
} => {
if self.finished {
return Ok(Vec::new());
}
@@ -2684,11 +2955,22 @@ impl OpenAIResponsesClientEmitter {
out.extend(self.finish_tool_items()?);
out.extend(self.finish_tool_result_items()?);
let usage = usage.unwrap_or_default();
let (event_type, response) = match finish_reason.as_deref() {
Some("length") => (
"response.incomplete",
self.incomplete_response(usage, "max_output_tokens"),
),
Some("content_filter") => (
"response.incomplete",
self.incomplete_response(usage, "content_filter"),
),
_ => ("response.completed", self.completed_response(usage)),
};
out.extend(self.encode_response_event(
"response.completed",
event_type,
json!({
"type": "response.completed",
"response": self.completed_response(usage),
"type": event_type,
"response": response,
}),
)?);
self.finished = true;
@@ -2761,6 +3043,95 @@ fn openai_tool_result_content_from_value(value: Option<&Value>) -> String {
}
}
fn tool_arguments_from_maybe_json_string(value: Option<&Value>, fallback_key: &str) -> String {
match value {
Some(Value::String(text)) => {
let trimmed = text.trim();
if trimmed.is_empty() {
return String::new();
}
match serde_json::from_str::<Value>(trimmed) {
Ok(Value::Object(_)) => trimmed.to_string(),
Ok(parsed) => single_field_tool_arguments(fallback_key, parsed),
Err(_) => single_field_tool_arguments(fallback_key, Value::String(text.clone())),
}
}
Some(value @ Value::Object(_)) => value.to_string(),
Some(Value::Null) | None => String::new(),
Some(value) => single_field_tool_arguments(fallback_key, value.clone()),
}
}
fn single_field_tool_arguments(key: &str, value: Value) -> String {
let mut arguments = Map::new();
arguments.insert(key.to_string(), value);
Value::Object(arguments).to_string()
}
fn tool_arguments_from_named_fields(item: &Map<String, Value>, field_names: &[&str]) -> String {
let mut arguments = Map::new();
for field_name in field_names {
if let Some(value) = item.get(*field_name) {
arguments.insert((*field_name).to_string(), value.clone());
}
}
if arguments.is_empty() {
String::new()
} else {
Value::Object(arguments).to_string()
}
}
fn openai_responses_stream_event_is_known_noop(event_type: &str) -> bool {
matches!(
event_type,
"response.queued"
| "response.output_text.annotation.added"
| "response.audio.delta"
| "response.audio.done"
| "response.code_interpreter_call.in_progress"
| "response.code_interpreter_call.interpreting"
| "response.code_interpreter_call.completed"
| "response.code_interpreter_call_code.delta"
| "response.code_interpreter_call_code.done"
| "response.file_search_call.in_progress"
| "response.file_search_call.searching"
| "response.file_search_call.completed"
| "response.image_generation_call.in_progress"
| "response.image_generation_call.generating"
| "response.image_generation_call.partial_image"
| "response.image_generation_call.completed"
| "response.mcp_call.in_progress"
| "response.mcp_call.completed"
| "response.mcp_call.failed"
| "response.mcp_call_arguments.delta"
| "response.mcp_call_arguments.done"
| "response.mcp_list_tools.in_progress"
| "response.mcp_list_tools.completed"
| "response.mcp_list_tools.failed"
| "response.web_search_call.in_progress"
| "response.web_search_call.searching"
| "response.web_search_call.completed"
)
}
fn openai_responses_incomplete_finish_reason(payload: &Value) -> String {
let reason = payload
.get("response")
.and_then(Value::as_object)
.and_then(|response| response.get("incomplete_details"))
.and_then(Value::as_object)
.and_then(|details| details.get("reason"))
.and_then(Value::as_str)
.unwrap_or_default();
match reason {
"content_filter" => "content_filter",
"tool_calls" | "function_call" => "tool_calls",
_ => "length",
}
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -738,6 +738,34 @@ fn strip_codex_hosted_tool_choice_name_for_backend(
}
}
fn wrap_codex_responses_string_input_for_backend(
body_object: &mut serde_json::Map<String, Value>,
provider_api_format: &str,
) {
if !aether_ai_formats::is_openai_responses_family_format(provider_api_format) {
return;
}
let Some(text) = body_object
.get("input")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
else {
return;
};
body_object.insert(
"input".to_string(),
json!([{
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": text,
}],
}]),
);
}
pub fn apply_codex_openai_responses_special_body_edits(
provider_request_body: &mut Value,
provider_type: &str,
@@ -760,6 +788,7 @@ pub fn apply_codex_openai_responses_special_body_edits(
return;
};
wrap_codex_responses_string_input_for_backend(body_object, provider_api_format);
for field in CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS {
if !body_rules_handle_path(body_rules, field) {
body_object.remove(*field);
@@ -985,6 +1014,34 @@ mod tests {
assert_eq!(provider_request_body["parallel_tool_calls"], json!(false));
}
#[test]
fn codex_responses_body_edits_wrap_string_input_for_backend() {
let mut provider_request_body = json!({
"input": "hello",
"model": "gpt-5.4"
});
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
"codex",
"openai:responses",
None,
None,
);
assert_eq!(
provider_request_body["input"],
json!([{
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "hello"
}]
}])
);
}
#[test]
fn codex_responses_body_edits_preserve_function_tools_for_codex_backend() {
let mut provider_request_body = json!({
@@ -1,10 +1,12 @@
use std::collections::BTreeMap;
use std::collections::{BTreeMap, VecDeque};
use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort,
formats::openai::shared::{
map_thinking_budget_to_openai_reasoning_effort, OpenAiResponsesReasoningEffort,
},
protocol::canonical::{
canonical_response_format_to_openai, canonicalize_tool_arguments,
is_claude_messages_request, is_claude_system_instruction, is_claude_thinking_block,
@@ -182,6 +184,10 @@ pub fn to_raw(
output.insert("reasoning".to_string(), reasoning);
}
output.extend(chat_openai_extension_object_to_responses(
&canonical.extensions,
&output,
));
output.extend(namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
@@ -200,6 +206,33 @@ pub fn to_raw(
Some(Value::Object(output))
}
fn chat_openai_extension_object_to_responses(
extensions: &BTreeMap<String, Value>,
existing: &Map<String, Value>,
) -> Map<String, Value> {
const RESPONSES_COMPATIBLE_CHAT_FIELDS: &[&str] = &[
"stream",
"store",
"service_tier",
"safety_identifier",
"prompt_cache_key",
];
extensions
.get("openai")
.and_then(Value::as_object)
.map(|object| {
object
.iter()
.filter(|(key, _)| {
RESPONSES_COMPATIBLE_CHAT_FIELDS.contains(&key.as_str())
&& !existing.contains_key(*key)
})
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
})
.unwrap_or_default()
}
fn canonical_instructions_to_responses(canonical: &CanonicalRequest) -> Option<Value> {
let text = canonical
.instructions
@@ -264,6 +297,8 @@ fn claude_system_instruction_to_responses_part(
fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option<Vec<Value>> {
let mut input = Vec::new();
let mut next_generated_tool_call_index = 0usize;
let mut pending_tool_call_ids = VecDeque::new();
for message in &canonical.messages {
let role = match message.role {
CanonicalRole::Assistant => "assistant",
@@ -282,10 +317,13 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
} => {
flush_responses_message(&mut input, role, &mut content);
saw_tool_item = true;
let call_id = responses_tool_call_id(id, &mut next_generated_tool_call_index);
let tool_name = responses_tool_name(name);
pending_tool_call_ids.push_back(call_id.clone());
input.push(json!({
"type": "function_call",
"call_id": id,
"name": name,
"call_id": call_id,
"name": tool_name,
"arguments": canonicalize_tool_arguments(arguments),
}));
}
@@ -302,10 +340,12 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
output.as_ref(),
content_text.as_deref(),
extensions,
);
)?;
let call_id =
responses_tool_result_call_id(tool_use_id, &mut pending_tool_call_ids)?;
input.push(json!({
"type": "function_call_output",
"call_id": tool_use_id,
"call_id": call_id,
"output": tool_output,
}));
if !extra_user_content.is_empty() {
@@ -360,6 +400,42 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
Some(input)
}
fn responses_tool_call_id(id: &str, next_generated_tool_call_index: &mut usize) -> String {
let trimmed = id.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
let generated = format!("call_auto_{next_generated_tool_call_index}");
*next_generated_tool_call_index += 1;
generated
}
fn responses_tool_result_call_id(
id: &str,
pending_tool_call_ids: &mut VecDeque<String>,
) -> Option<String> {
let trimmed = id.trim();
if !trimmed.is_empty() {
if let Some(position) = pending_tool_call_ids
.iter()
.position(|pending_id| pending_id == trimmed)
{
pending_tool_call_ids.remove(position);
}
return Some(trimmed.to_string());
}
pending_tool_call_ids.pop_front()
}
fn responses_tool_name(name: &str) -> String {
let trimmed = name.trim();
if trimmed.is_empty() {
"unknown".to_string()
} else {
trimmed.to_string()
}
}
fn responses_max_output_tokens(canonical: &CanonicalRequest) -> Option<u64> {
canonical.generation.max_tokens.map(|max_tokens| {
if is_claude_messages_request(&canonical.extensions) && max_tokens < 128 {
@@ -582,7 +658,7 @@ fn canonical_reasoning_config_to_responses(canonical: &CanonicalRequest) -> Opti
.and_then(|value| value.get("output_config"))
.and_then(|value| value.get("effort"))
.and_then(Value::as_str)
.map(openai_responses_reasoning_effort)
.and_then(openai_responses_reasoning_effort)
.unwrap_or("medium");
object
.entry("effort".to_string())
@@ -608,10 +684,11 @@ fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<V
.get("openai")
.and_then(|value| value.get("reasoning_effort"))
.and_then(Value::as_str)
.map(|effort| {
json!({
"effort": openai_responses_reasoning_effort(effort),
})
.and_then(|effort| {
let effort = openai_responses_reasoning_effort(effort)?;
Some(json!({
"effort": effort,
}))
})
})
.or_else(|| {
@@ -623,13 +700,12 @@ fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<V
})
}
fn openai_responses_reasoning_effort(effort: &str) -> &str {
fn openai_responses_reasoning_effort(effort: &str) -> Option<&'static str> {
match effort.trim().to_ascii_lowercase().as_str() {
"xhigh" | "max" => "xhigh",
"low" => "low",
"medium" => "medium",
"high" => "high",
_ => effort,
"max" => Some("xhigh"),
value => {
OpenAiResponsesReasoningEffort::parse(value).map(OpenAiResponsesReasoningEffort::as_str)
}
}
}
@@ -694,6 +770,9 @@ fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
"parameters".to_string(),
responses_tool_parameters_schema(tool.parameters.as_ref()),
);
if let Some(strict) = tool.strict {
out.insert("strict".to_string(), Value::Bool(strict));
}
out.extend(namespace_extension_object(
&tool.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
@@ -737,16 +816,16 @@ fn responses_tool_result_payload(
output: Option<&Value>,
content_text: Option<&str>,
extensions: &BTreeMap<String, Value>,
) -> (Value, Vec<Value>) {
) -> Option<(Value, Vec<Value>)> {
if is_claude_tool_result(extensions) {
if let Some(Value::Array(parts)) = output {
return claude_tool_result_parts_to_responses_payload(parts);
}
}
(
Some((
responses_tool_result_output(output, content_text),
Vec::new(),
)
))
}
fn responses_tool_result_output(output: Option<&Value>, content_text: Option<&str>) -> Value {
@@ -759,15 +838,64 @@ fn responses_tool_result_output(output: Option<&Value>, content_text: Option<&st
Value::String(non_empty_responses_tool_output(&text))
}
fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> (Value, Vec<Value>) {
pub(crate) fn claude_tool_result_parts_are_openai_responses_representable(parts: &[Value]) -> bool {
parts
.iter()
.all(claude_tool_result_part_is_openai_responses_representable)
}
fn claude_tool_result_part_is_openai_responses_representable(part: &Value) -> bool {
let Some(part_object) = part.as_object() else {
return false;
};
match part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"text" => true,
"image" => claude_image_block_is_openai_responses_representable(part_object),
"document" | "file" => claude_document_block_is_openai_responses_representable(part_object),
_ => false,
}
}
fn claude_image_block_is_openai_responses_representable(block: &Map<String, Value>) -> bool {
let Some(source) = block.get("source").and_then(Value::as_object) else {
return false;
};
match source
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"base64" => claude_source_str(source, "data").is_some(),
"url" => claude_source_str(source, "url").is_some(),
_ => false,
}
}
fn claude_document_block_is_openai_responses_representable(block: &Map<String, Value>) -> bool {
let Some(source) = block.get("source").and_then(Value::as_object) else {
return false;
};
match source
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"base64" | "text" => claude_source_str(source, "data").is_some(),
"url" => claude_source_str(source, "url").is_some(),
_ => false,
}
}
fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> Option<(Value, Vec<Value>)> {
let mut output_texts = Vec::new();
let mut extra_user_content = Vec::new();
for part in parts {
let Some(part_object) = part.as_object() else {
output_texts.push("[Claude tool_result non-text content omitted]".to_string());
continue;
};
let part_object = part.as_object()?;
match part_object
.get("type")
.and_then(Value::as_str)
@@ -784,27 +912,31 @@ fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> (Value, Vec
if let Some(part) = claude_image_block_to_responses_input_part(part_object) {
extra_user_content.push(part);
} else {
output_texts.push(claude_tool_result_media_summary("image", part_object));
return None;
}
}
"document" | "file" => {
if let Some(part) = claude_document_block_to_responses_input_part(part_object) {
if let Some(text) = claude_text_document_block_to_responses_output_text(part_object)
{
if !text.is_empty() {
output_texts.push(text.to_string());
}
} else if let Some(part) =
claude_document_block_to_responses_input_part(part_object)
{
extra_user_content.push(part);
} else {
output_texts.push(claude_tool_result_media_summary("document", part_object));
return None;
}
}
"" => output_texts.push("[Claude tool_result object content omitted]".to_string()),
raw_type => {
output_texts.push(format!("[Claude tool_result {raw_type} content omitted]"))
}
_ => return None,
}
}
(
Some((
Value::String(non_empty_responses_tool_output(&output_texts.join("\n\n"))),
extra_user_content,
)
))
}
fn claude_image_block_to_responses_input_part(block: &Map<String, Value>) -> Option<Value> {
@@ -863,16 +995,15 @@ fn claude_document_block_to_responses_input_part(block: &Map<String, Value>) ->
Some(Value::Object(part))
}
fn claude_tool_result_media_summary(kind: &str, block: &Map<String, Value>) -> String {
let media_type = block
.get("source")
.and_then(Value::as_object)
.and_then(claude_source_media_type);
match media_type {
Some(media_type) if !media_type.trim().is_empty() => {
format!("[Claude tool_result {kind} content omitted: {media_type}]")
}
_ => format!("[Claude tool_result {kind} content omitted]"),
fn claude_text_document_block_to_responses_output_text(block: &Map<String, Value>) -> Option<&str> {
let source = block.get("source")?.as_object()?;
match source
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"text" => claude_source_str(source, "data"),
_ => None,
}
}
@@ -913,6 +1044,16 @@ mod tests {
CanonicalRole,
};
use serde_json::json;
use std::collections::BTreeMap;
fn claude_tool_result_extensions() -> BTreeMap<String, serde_json::Value> {
let mut extensions = BTreeMap::new();
extensions.insert(
"aether".to_string(),
json!({ "source": "claude_tool_result" }),
);
extensions
}
#[test]
fn json_object_response_injects_json_hint_into_input_when_only_instructions_have_it() {
@@ -938,12 +1079,17 @@ mod tests {
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
assert_eq!(body["text"]["format"]["type"], json!("json_object"));
assert_eq!(body["input"][0]["role"], json!("system"));
assert!(body["input"][0]["content"][0]["text"]
assert_eq!(body["instructions"], json!("Please answer in JSON."));
let input = body["input"].as_array().expect("input");
assert_eq!(input.len(), 2);
assert_eq!(input[0]["role"], json!("system"));
assert!(input[0]["content"][0]["text"]
.as_str()
.expect("hint text")
.to_ascii_lowercase()
.contains("json"));
assert_eq!(input[1]["role"], json!("user"));
assert_eq!(input[1]["content"][0]["text"], json!("hello"));
}
#[test]
@@ -1003,4 +1149,192 @@ mod tests {
assert_eq!(body["input"][0]["call_id"], "call_empty");
assert_eq!(body["input"][0]["output"], "(empty)");
}
#[test]
fn responses_request_replaces_empty_tool_call_identifiers() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
messages: vec![
CanonicalMessage {
role: CanonicalRole::Assistant,
content: vec![CanonicalContentBlock::ToolUse {
id: " ".to_string(),
name: "".to_string(),
input: json!({"q": "rust"}),
extensions: Default::default(),
}],
extensions: Default::default(),
},
CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![CanonicalContentBlock::ToolResult {
tool_use_id: "".to_string(),
name: None,
output: Some(json!({"ok": true})),
content_text: None,
is_error: false,
extensions: Default::default(),
}],
extensions: Default::default(),
},
],
..CanonicalRequest::default()
};
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
assert_eq!(body["input"].as_array().expect("input").len(), 2);
assert_eq!(body["input"][0]["type"], "function_call");
assert_eq!(body["input"][0]["call_id"], "call_auto_0");
assert_eq!(body["input"][0]["name"], "unknown");
assert_eq!(body["input"][0]["arguments"], "{\"q\":\"rust\"}");
assert_eq!(body["input"][1]["type"], "function_call_output");
assert_eq!(body["input"][1]["call_id"], "call_auto_0");
}
#[test]
fn responses_request_assigns_empty_tool_result_identifiers_from_pending_tool_calls_in_order() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
messages: vec![
CanonicalMessage {
role: CanonicalRole::Assistant,
content: vec![
CanonicalContentBlock::ToolUse {
id: "call_a".to_string(),
name: "lookup_a".to_string(),
input: json!({"q": "a"}),
extensions: Default::default(),
},
CanonicalContentBlock::ToolUse {
id: "call_b".to_string(),
name: "lookup_b".to_string(),
input: json!({"q": "b"}),
extensions: Default::default(),
},
],
extensions: Default::default(),
},
CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![
CanonicalContentBlock::ToolResult {
tool_use_id: " ".to_string(),
name: None,
output: Some(json!("result a")),
content_text: None,
is_error: false,
extensions: Default::default(),
},
CanonicalContentBlock::ToolResult {
tool_use_id: "".to_string(),
name: None,
output: Some(json!("result b")),
content_text: None,
is_error: false,
extensions: Default::default(),
},
],
extensions: Default::default(),
},
],
..CanonicalRequest::default()
};
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
assert_eq!(body["input"][0]["call_id"], "call_a");
assert_eq!(body["input"][1]["call_id"], "call_b");
assert_eq!(body["input"][2]["call_id"], "call_a");
assert_eq!(body["input"][2]["output"], "result a");
assert_eq!(body["input"][3]["call_id"], "call_b");
assert_eq!(body["input"][3]["output"], "result b");
}
#[test]
fn responses_request_rejects_orphan_empty_tool_result_identifier() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
messages: vec![CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![CanonicalContentBlock::ToolResult {
tool_use_id: " ".to_string(),
name: None,
output: Some(json!({"ok": true})),
content_text: None,
is_error: false,
extensions: Default::default(),
}],
extensions: Default::default(),
}],
..CanonicalRequest::default()
};
assert!(to_raw(&request, "gpt-5.5", false, false).is_none());
}
#[test]
fn responses_request_preserves_claude_text_document_tool_result_content() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
messages: vec![CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![CanonicalContentBlock::ToolResult {
tool_use_id: "call_doc".to_string(),
name: None,
output: Some(json!([
{"type": "text", "text": "preview"},
{
"type": "document",
"source": {
"type": "text",
"media_type": "text/plain",
"data": "document body"
}
}
])),
content_text: None,
is_error: false,
extensions: claude_tool_result_extensions(),
}],
extensions: Default::default(),
}],
..CanonicalRequest::default()
};
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
assert_eq!(body["input"][0]["type"], "function_call_output");
assert_eq!(body["input"][0]["output"], "preview\n\ndocument body");
assert!(!body.to_string().contains("content omitted"));
}
#[test]
fn responses_request_rejects_unrepresentable_claude_tool_result_blocks() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
messages: vec![CanonicalMessage {
role: CanonicalRole::Tool,
content: vec![CanonicalContentBlock::ToolResult {
tool_use_id: "call_img".to_string(),
name: None,
output: Some(json!([{
"type": "image",
"source": {
"type": "unsupported",
"media_type": "image/png",
"data": "AAAA"
}
}])),
content_text: None,
is_error: false,
extensions: claude_tool_result_extensions(),
}],
extensions: Default::default(),
}],
..CanonicalRequest::default()
};
assert!(to_raw(&request, "gpt-5.5", false, false).is_none());
}
}
@@ -8,9 +8,9 @@ use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
protocol::canonical::{
canonical_content_block_to_openai_responses_part,
canonical_content_block_to_openai_responses_part, canonical_extension_object_mut,
canonical_usage_to_openai_responses_usage, canonicalize_tool_arguments,
flush_openai_responses_message_item, namespace_extension_object,
flush_openai_responses_message_item, is_openai_thinking_block, namespace_extension_object,
openai_responses_extensions, openai_responses_output_to_canonical_blocks,
openai_usage_to_canonical, CanonicalContentBlock, CanonicalResponse,
CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
@@ -45,11 +45,19 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
Some(CanonicalStopReason::ToolUse)
} else {
match body.get("status").and_then(Value::as_str) {
Some("incomplete") => Some(CanonicalStopReason::MaxTokens),
Some("incomplete") => Some(openai_responses_incomplete_stop_reason(body)),
Some("failed") => Some(CanonicalStopReason::Unknown),
_ => Some(CanonicalStopReason::EndTurn),
}
};
let mut extensions = openai_responses_extensions(
body,
&["id", "object", "model", "output", "usage", "status"],
);
if let Some(raw_status) = body.get("status").cloned() {
canonical_extension_object_mut(&mut extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.insert("raw_status".to_string(), raw_status);
}
Some(CanonicalResponse {
id: body
.get("id")
@@ -71,13 +79,23 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
content,
stop_reason,
usage: openai_usage_to_canonical(body.get("usage")),
extensions: openai_responses_extensions(
body,
&["id", "object", "model", "output", "usage", "status"],
),
extensions,
})
}
fn openai_responses_incomplete_stop_reason(body: &Map<String, Value>) -> CanonicalStopReason {
match body
.get("incomplete_details")
.and_then(Value::as_object)
.and_then(|details| details.get("reason"))
.and_then(Value::as_str)
{
Some("content_filter") => CanonicalStopReason::ContentFiltered,
Some("tool_calls") | Some("function_call") => CanonicalStopReason::ToolUse,
_ => CanonicalStopReason::MaxTokens,
}
}
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");
@@ -85,6 +103,20 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
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()));
if let Some(raw_status) = canonical
.extensions
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.or_else(|| {
canonical
.extensions
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
})
.and_then(Value::as_object)
.and_then(|openai| openai.get("raw_status"))
.cloned()
{
response.insert("status".to_string(), raw_status);
}
let mut output = Vec::new();
let mut message_content = Vec::new();
@@ -126,8 +158,16 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
CanonicalContentBlock::Thinking {
text,
encrypted_content,
extensions,
..
} => {
let encrypted_content = encrypted_content
.as_ref()
.filter(|value| !value.is_empty())
.filter(|_| is_openai_thinking_block(extensions));
if text.trim().is_empty() && encrypted_content.is_none() {
continue;
}
flush_openai_responses_message_item(
&mut output,
&mut message_content,
@@ -141,9 +181,7 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
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())
{
if let Some(encrypted_content) = encrypted_content {
item.insert(
"encrypted_content".to_string(),
Value::String(encrypted_content.clone()),
@@ -275,16 +313,20 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
response.insert("service_tier".to_string(), service_tier);
}
}
response.extend(namespace_extension_object(
let mut extension_fields = namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
&response,
));
response.extend(namespace_extension_object(
);
extension_fields.remove("raw_status");
response.extend(extension_fields);
let mut legacy_extension_fields = namespace_extension_object(
&canonical.extensions,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
&response,
));
);
legacy_extension_fields.remove("raw_status");
response.extend(legacy_extension_fields);
ensure_modern_openai_responses_response_fields(&mut response);
Value::Object(response)
}
@@ -497,6 +539,65 @@ mod tests {
assert_eq!(body["conversation"]["id"], "conv_123");
}
#[test]
fn responses_response_parser_preserves_encrypted_reasoning_without_summary() {
let body = json!({
"id": "resp_test",
"model": "gpt-5",
"status": "completed",
"output": [{
"type": "reasoning",
"id": "rs_1",
"status": "completed",
"summary": [],
"encrypted_content": "openai-opaque"
}]
});
let canonical = from_raw(&body).expect("response should parse");
assert!(matches!(
canonical.content.first(),
Some(CanonicalContentBlock::Thinking {
text,
encrypted_content,
..
}) if text.is_empty() && encrypted_content.as_deref() == Some("openai-opaque")
));
let rebuilt = to_raw(&canonical, &json!({}), false);
assert_eq!(rebuilt["output"][0]["type"], "reasoning");
assert_eq!(
rebuilt["output"][0]["encrypted_content"],
json!("openai-opaque")
);
}
#[test]
fn responses_response_builder_does_not_emit_claude_redacted_as_openai_encrypted_content() {
let mut extensions = BTreeMap::new();
extensions.insert("aether".to_string(), json!({"source": "claude_thinking"}));
let response = CanonicalResponse {
id: "msg_claude".to_string(),
model: "claude-sonnet".to_string(),
content: vec![CanonicalContentBlock::Thinking {
text: String::new(),
signature: None,
encrypted_content: Some("{\"type\":\"redacted_thinking\",\"v\":5}".to_string()),
extensions,
}],
outputs: Vec::new(),
stop_reason: Some(CanonicalStopReason::EndTurn),
usage: None,
extensions: BTreeMap::new(),
};
let body = to_raw(&response, &json!({}), false);
assert!(body["output"].as_array().expect("output").is_empty());
assert!(!body.to_string().contains("encrypted_content"));
}
#[test]
fn responses_response_parser_reads_web_search_call_as_tool_use() {
let body = json!({
@@ -2,6 +2,51 @@ use serde_json::{Map, Value};
use crate::formats::shared::model_directives::ReasoningEffort;
macro_rules! define_openai_reasoning_effort {
($name:ident) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum $name {
None,
Minimal,
Low,
Medium,
High,
XHigh,
}
impl $name {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"none" => Some(Self::None),
"minimal" => Some(Self::Minimal),
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"xhigh" => Some(Self::XHigh),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
}
}
}
};
}
define_openai_reasoning_effort!(OpenAiChatReasoningEffort);
define_openai_reasoning_effort!(OpenAiResponsesReasoningEffort);
#[deprecated(note = "use OpenAiChatReasoningEffort or OpenAiResponsesReasoningEffort")]
pub type OpenAiReasoningEffort = OpenAiChatReasoningEffort;
pub fn parse_openai_stop_sequences(stop: Option<&Value>) -> Option<Vec<Value>> {
match stop {
Some(Value::String(value)) if !value.trim().is_empty() => {
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,8 @@ pub enum ModelOverride {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReasoningEffort {
None,
Minimal,
Low,
Medium,
High,
@@ -24,6 +26,8 @@ pub enum ReasoningEffort {
impl ReasoningEffort {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"none" => Some(Self::None),
"minimal" => Some(Self::Minimal),
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
@@ -35,6 +39,8 @@ impl ReasoningEffort {
pub fn as_openai_chat_value(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
@@ -44,6 +50,8 @@ impl ReasoningEffort {
pub fn as_openai_responses_value(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
@@ -53,6 +61,7 @@ impl ReasoningEffort {
pub fn as_claude_output_value(self) -> &'static str {
match self {
Self::None | Self::Minimal => "low",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
@@ -63,7 +72,7 @@ impl ReasoningEffort {
pub fn as_gemini_level_value(self) -> &'static str {
match self {
Self::Low => "low",
Self::None | Self::Minimal | Self::Low => "low",
Self::Medium => "medium",
Self::High | Self::XHigh | Self::Max => "high",
}
@@ -71,6 +80,8 @@ impl ReasoningEffort {
pub fn thinking_budget_tokens(self) -> u64 {
match self {
Self::None => 0,
Self::Minimal => 512,
Self::Low => 1280,
Self::Medium => 2048,
Self::High => 4096,
@@ -2,19 +2,17 @@ use serde_json::Value;
use crate::formats::shared::AiSurfaceFinalizeError;
pub fn map_claude_stop_reason(
stop_reason: Option<&str>,
has_tool_calls: bool,
) -> Option<&'static str> {
pub fn map_claude_stop_reason(stop_reason: Option<&str>, has_tool_calls: bool) -> Option<String> {
let mapped = match stop_reason {
Some("end_turn") | Some("stop_sequence") => Some("stop"),
Some("max_tokens") => Some("length"),
Some("tool_use") => Some("tool_calls"),
Some("pause_turn") => Some("stop"),
Some("end_turn") | Some("stop_sequence") => Some("stop".to_string()),
Some("max_tokens") => Some("length".to_string()),
Some("tool_use") => Some("tool_calls".to_string()),
Some("pause_turn") => Some("stop".to_string()),
Some(other) if !other.trim().is_empty() => Some(other.to_string()),
_ => None,
};
if has_tool_calls && mapped.is_none_or(|value| value == "stop") {
Some("tool_calls")
if has_tool_calls && mapped.as_deref().is_none_or(|value| value == "stop") {
Some("tool_calls".to_string())
} else {
mapped
}
@@ -19,6 +19,93 @@ pub fn decode_json_data_line(line: &[u8]) -> Option<Value> {
serde_json::from_str(data_line).ok()
}
pub fn unsupported_stream_event_message(payload: &Value) -> String {
const BASE_MESSAGE: &str = "Unsupported provider stream event cannot be converted losslessly";
match unsupported_stream_event_diagnostic(payload) {
Some(diagnostic) if !diagnostic.is_empty() => format!("{BASE_MESSAGE}: {diagnostic}"),
_ => BASE_MESSAGE.to_string(),
}
}
fn unsupported_stream_event_diagnostic(payload: &Value) -> Option<String> {
let mut details = Vec::new();
if let Some((path, value)) = unsupported_stream_event_primary_field(payload) {
details.push(format!("field {path} = {value}"));
} else if let Some(path) = unsupported_stream_event_single_field(payload) {
details.push(format!("field {path} is unsupported"));
}
if let Some(fields) = unsupported_stream_event_field_list(payload) {
details.push(format!("fields: {fields}"));
}
if details.is_empty() {
None
} else {
Some(details.join("; "))
}
}
fn unsupported_stream_event_primary_field(payload: &Value) -> Option<(&'static str, String)> {
const STRING_FIELD_PATHS: &[(&str, &str)] = &[
("$.item.type", "/item/type"),
("$.content_block.type", "/content_block/type"),
("$.delta.type", "/delta/type"),
("$.part.type", "/part/type"),
("$.payload.type", "/payload/type"),
("$.type", "/type"),
("$.event", "/event"),
];
STRING_FIELD_PATHS
.iter()
.find_map(|(display_path, pointer)| {
payload
.pointer(pointer)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(|value| (*display_path, json!(value.trim()).to_string()))
})
}
fn unsupported_stream_event_single_field(payload: &Value) -> Option<String> {
let object = payload.as_object()?;
if object.len() != 1 {
return None;
}
object.keys().next().map(|key| json_path_key(key))
}
fn unsupported_stream_event_field_list(payload: &Value) -> Option<String> {
let object = payload.as_object()?;
if object.is_empty() {
return None;
}
let fields = object
.keys()
.take(8)
.map(|key| key.as_str())
.collect::<Vec<_>>()
.join(", ");
if object.len() > 8 {
Some(format!("{fields}, ..."))
} else {
Some(fields)
}
}
fn json_path_key(key: &str) -> String {
if !key.is_empty()
&& key.chars().enumerate().all(|(index, ch)| {
ch == '_' || ch.is_ascii_alphabetic() || (index > 0 && ch.is_ascii_digit())
})
{
format!("$.{key}")
} else {
format!("$[{}]", json!(key))
}
}
pub fn resolve_identity(
response_id: Option<&str>,
model: Option<&str>,
@@ -110,26 +197,27 @@ pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<Canoni
}
pub fn openai_stream_payload_is_terminal_error(payload: &Value) -> bool {
let response = payload.get("response").and_then(Value::as_object);
if payload.get("error").is_some_and(|error| !error.is_null())
|| response
.and_then(|response| response.get("error"))
.is_some_and(|error| !error.is_null())
{
return true;
}
let event_type = payload
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if payload.get("error").is_some() {
return true;
}
if matches!(
event_type,
"error" | "response.failed" | "response.incomplete"
) {
if matches!(event_type, "error" | "response.failed") {
return true;
}
payload
.get("response")
.and_then(Value::as_object)
response
.and_then(|response| response.get("status"))
.and_then(Value::as_str)
.is_some_and(|status| matches!(status, "failed" | "incomplete"))
.is_some_and(|status| status == "failed")
}
pub fn openai_stream_terminal_error_body(payload: &Value) -> Option<Value> {
@@ -699,3 +787,38 @@ fn inclusive_total_tokens_from_usage(usage: &CanonicalUsage, input_tokens: u64)
input_tokens.saturating_add(usage.output_tokens)
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
openai_stream_payload_is_terminal_error, openai_stream_terminal_error_body,
openai_stream_terminal_error_message,
};
#[test]
fn completed_openai_responses_payload_with_null_error_is_not_terminal_error() {
let payload = json!({
"type": "response.completed",
"response": {
"id": "resp_123",
"object": "response",
"status": "completed",
"error": null,
"incomplete_details": null,
"output": [],
"usage": {
"input_tokens": 1,
"output_tokens": 2,
"total_tokens": 3
}
},
"error": null
});
assert!(!openai_stream_payload_is_terminal_error(&payload));
assert!(openai_stream_terminal_error_body(&payload).is_none());
assert!(openai_stream_terminal_error_message(&payload).is_none());
}
}
@@ -15,7 +15,7 @@ use crate::formats::shared::error_body::{
use crate::formats::shared::sse::encode_json_sse;
use crate::formats::shared::stream_core::common::{
decode_json_data_line, openai_stream_terminal_error_body, openai_stream_terminal_error_message,
CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
unsupported_stream_event_message, CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
};
use crate::formats::shared::AiSurfaceFinalizeError;
@@ -84,6 +84,22 @@ impl StreamingStandardFormatMatrix {
};
let mut out = Vec::new();
for frame in frames {
if let CanonicalStreamEvent::Finish {
finish_reason: Some(ref finish_reason),
..
} = frame.event
{
if !canonical_stream_finish_reason_is_supported(finish_reason) {
self.terminated = true;
out.extend(client.emit_unsupported_finish_reason(finish_reason)?);
break;
}
}
if let CanonicalStreamEvent::UnknownEvent(payload) = &frame.event {
self.terminated = true;
out.extend(client.emit_unknown_event(payload)?);
break;
}
out.extend(client.emit(frame)?);
}
Ok(out)
@@ -386,6 +402,52 @@ impl ClientStreamEmitter {
}
}
}
fn emit_unknown_event(&mut self, payload: &Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let Some(error_body) = build_core_error_body_for_client_format(
self.api_format(),
&unsupported_stream_event_message(payload),
Some("unsupported_stream_event"),
LocalCoreSyncErrorKind::ServerError,
) else {
return Ok(Vec::new());
};
self.emit_error(error_body)
}
fn emit_unsupported_finish_reason(
&mut self,
finish_reason: &str,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let Some(error_body) = build_core_error_body_for_client_format(
self.api_format(),
&format!(
"Unsupported provider stream finish reason cannot be converted losslessly: field $.finish_reason = {}",
serde_json::json!(finish_reason)
),
Some("unsupported_finish_reason"),
LocalCoreSyncErrorKind::ServerError,
) else {
return Ok(Vec::new());
};
self.emit_error(error_body)
}
fn api_format(&self) -> &'static str {
match self {
ClientStreamEmitter::OpenAIChat(_) => "openai:chat",
ClientStreamEmitter::OpenAIResponses(_) => "openai:responses",
ClientStreamEmitter::Claude(_) => "claude:messages",
ClientStreamEmitter::Gemini(_) => "gemini:generate_content",
}
}
}
fn canonical_stream_finish_reason_is_supported(finish_reason: &str) -> bool {
matches!(
finish_reason.trim(),
"stop" | "length" | "tool_calls" | "function_call" | "content_filter"
)
}
fn build_client_error_body_for_line(report_context: &Value, line: &[u8]) -> Option<Value> {
@@ -850,6 +912,485 @@ mod tests {
}
}
#[test]
fn transforms_unknown_provider_stream_events_to_visible_client_errors() {
let cases = [
(
"openai:chat",
"data: {\"error\":",
"\"code\":\"unsupported_stream_event\"",
),
(
"openai:responses",
"event: response.failed\n",
"\"code\":\"unsupported_stream_event\"",
),
(
"claude:messages",
"event: error\n",
"\"code\":\"unsupported_stream_event\"",
),
(
"gemini:generate_content",
"data: {\"error\":",
"\"status\":\"INTERNAL\"",
),
];
for (client_api_format, prefix, marker) in cases {
let mut report_context = report_context("openai:responses", client_api_format);
report_context["provider_stream_event_api_format"] = json!("openai:responses");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(
&report_context,
data_line(json!({
"type": "response.future.delta",
"response": {
"id": "resp_unknown_123",
"model": "gpt-5.4",
},
"payload": {
"kept": true,
},
})),
)
.expect("unknown provider event should fail closed visibly");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.contains(prefix), "{client_api_format}: {sse}");
assert!(
sse.contains("Unsupported provider stream event cannot be converted losslessly"),
"{client_api_format}: {sse}"
);
assert!(
sse.contains("field $.type = \\\"response.future.delta\\\""),
"{sse}"
);
assert!(sse.contains(marker), "{client_api_format}: {sse}");
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
assert!(matrix
.transform_line(
&report_context,
data_line(json!({
"type": "response.output_text.delta",
"response_id": "resp_unknown_123",
"output_index": 0,
"content_index": 0,
"delta": "after",
})),
)
.expect("terminated matrix should ignore later lines")
.is_empty());
}
}
#[test]
fn transforms_openai_responses_known_sidecar_events_without_unsupported_errors() {
let report_context = report_context("openai:responses", "claude:messages");
let mut matrix = StreamingStandardFormatMatrix::default();
let mut output = Vec::new();
for line in [
data_line(json!({
"type": "response.created",
"response": {
"id": "resp_sidecar_123",
"model": "gpt-5.4",
"status": "in_progress",
"output": [],
},
})),
data_line(json!({
"type": "response.output_item.added",
"response_id": "resp_sidecar_123",
"output_index": 0,
"item": {
"type": "web_search_call",
"id": "ws_123",
"status": "in_progress",
"action": {"type": "search", "query": "aether format conversion"},
},
})),
data_line(json!({
"type": "response.web_search_call.searching",
"item_id": "ws_123",
"output_index": 0,
})),
data_line(json!({
"type": "response.output_text.annotation.added",
"response_id": "resp_sidecar_123",
"output_index": 1,
"content_index": 0,
"annotation_index": 0,
"annotation": {"type": "url_citation", "url": "https://example.invalid"},
})),
data_line(json!({
"type": "response.output_text.delta",
"response_id": "resp_sidecar_123",
"output_index": 1,
"content_index": 0,
"delta": "sidecar ok",
})),
data_line(json!({
"type": "response.completed",
"response": {
"id": "resp_sidecar_123",
"object": "response",
"model": "gpt-5.4",
"status": "completed",
"output": [],
"usage": {
"input_tokens": 1,
"output_tokens": 2,
"total_tokens": 3,
},
},
})),
] {
output.extend(
matrix
.transform_line(&report_context, line)
.expect("known responses sidecar event should convert or be ignored"),
);
}
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(!sse.contains("unsupported_stream_event"), "{sse}");
assert!(!sse.contains("Unsupported provider stream event"), "{sse}");
assert!(sse.contains("sidecar ok"), "{sse}");
assert!(sse.contains("event: message_stop"), "{sse}");
}
#[test]
fn transforms_openai_responses_incomplete_max_tokens_as_normal_finish() {
let report_context = report_context("openai:responses", "claude:messages");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(
&report_context,
data_line(json!({
"type": "response.incomplete",
"response": {
"id": "resp_incomplete_123",
"object": "response",
"model": "gpt-5.4",
"status": "incomplete",
"incomplete_details": {
"reason": "max_output_tokens",
},
"output": [{
"type": "message",
"id": "msg_incomplete_123",
"role": "assistant",
"status": "incomplete",
"content": [{
"type": "output_text",
"text": "partial answer",
}],
}],
"usage": {
"input_tokens": 10,
"output_tokens": 20,
"total_tokens": 30,
},
},
})),
)
.expect("incomplete max token response should convert as length finish");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(!sse.contains("Response incomplete"), "{sse}");
assert!(!sse.contains("unsupported_stream_event"), "{sse}");
assert!(sse.contains("partial answer"), "{sse}");
assert!(sse.contains("\"stop_reason\":\"max_tokens\""), "{sse}");
assert!(matrix
.finish(&report_context)
.expect("finish should be terminated")
.is_empty());
}
#[test]
fn transforms_openai_responses_local_shell_call_to_claude_tool_use() {
let report_context = report_context("openai:responses", "claude:messages");
let mut matrix = StreamingStandardFormatMatrix::default();
let mut output = Vec::new();
for line in [
data_line(json!({
"type": "response.output_item.done",
"response_id": "resp_shell_123",
"output_index": 0,
"item": {
"type": "local_shell_call",
"id": "lsc_123",
"call_id": "call_shell_123",
"status": "completed",
"action": {
"type": "exec",
"command": ["pwd"],
"env": {},
},
},
})),
data_line(json!({
"type": "response.completed",
"response": {
"id": "resp_shell_123",
"object": "response",
"model": "gpt-5.4",
"status": "completed",
"output": [],
},
})),
] {
output.extend(
matrix
.transform_line(&report_context, line)
.expect("local shell call should convert to a generic tool use"),
);
}
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(!sse.contains("unsupported_stream_event"), "{sse}");
assert!(sse.contains("\"type\":\"tool_use\""), "{sse}");
assert!(sse.contains("\"name\":\"local_shell\""), "{sse}");
assert!(sse.contains("\\\"command\\\":[\\\"pwd\\\"]"), "{sse}");
assert!(sse.contains("\"stop_reason\":\"tool_use\""), "{sse}");
}
#[test]
fn transforms_unknown_stream_finish_reasons_to_visible_client_errors() {
let cases = [
(
"openai:chat",
"data: {\"error\":",
"\"code\":\"unsupported_finish_reason\"",
),
(
"openai:responses",
"event: response.failed\n",
"\"code\":\"unsupported_finish_reason\"",
),
(
"claude:messages",
"event: error\n",
"\"code\":\"unsupported_finish_reason\"",
),
(
"gemini:generate_content",
"data: {\"error\":",
"\"status\":\"INTERNAL\"",
),
];
for (client_api_format, prefix, marker) in cases {
let report_context = report_context("openai:chat", client_api_format);
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(
&report_context,
data_line(json!({
"id": "chatcmpl_unknown_finish",
"object": "chat.completion.chunk",
"model": "gpt-5.4",
"choices": [{
"index": 0,
"delta": {},
"finish_reason": "future_reason"
}],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 2,
"total_tokens": 3
}
})),
)
.expect("unknown finish reason should fail closed visibly");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.contains(prefix), "{client_api_format}: {sse}");
assert!(
sse.contains("Unsupported provider stream finish reason"),
"{client_api_format}: {sse}"
);
assert!(
sse.contains("field $.finish_reason = \\\"future_reason\\\""),
"{client_api_format}: {sse}"
);
assert!(sse.contains("future_reason"), "{client_api_format}: {sse}");
assert!(sse.contains(marker), "{client_api_format}: {sse}");
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
}
#[test]
fn transforms_unmappable_gemini_stream_finish_reasons_to_visible_client_errors() {
let cases = [
(
"openai:chat",
"data: {\"error\":",
"\"code\":\"unsupported_finish_reason\"",
),
(
"openai:responses",
"event: response.failed\n",
"\"code\":\"unsupported_finish_reason\"",
),
(
"claude:messages",
"event: error\n",
"\"code\":\"unsupported_finish_reason\"",
),
(
"gemini:generate_content",
"data: {\"error\":",
"\"status\":\"INTERNAL\"",
),
];
for (client_api_format, prefix, marker) in cases {
let report_context = report_context("gemini:generate_content", client_api_format);
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(
&report_context,
data_line(json!({
"responseId": "gemini_unmappable_finish",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"index": 0,
"content": {
"role": "model",
"parts": [{"text": "partial"}]
},
"finishReason": "OTHER"
}],
"usageMetadata": {
"promptTokenCount": 1,
"candidatesTokenCount": 2,
"totalTokenCount": 3
}
})),
)
.expect("unmappable Gemini finish reason should fail closed visibly");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.contains(prefix), "{client_api_format}: {sse}");
assert!(
sse.contains("Unsupported provider stream finish reason"),
"{client_api_format}: {sse}"
);
assert!(
sse.contains("field $.finish_reason = \\\"OTHER\\\""),
"{client_api_format}: {sse}"
);
assert!(sse.contains("OTHER"), "{client_api_format}: {sse}");
assert!(sse.contains(marker), "{client_api_format}: {sse}");
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
}
#[test]
fn transforms_unknown_claude_stream_stop_reasons_to_visible_client_errors() {
let cases = [
(
"openai:chat",
"data: {\"error\":",
"\"code\":\"unsupported_finish_reason\"",
),
(
"openai:responses",
"event: response.failed\n",
"\"code\":\"unsupported_finish_reason\"",
),
(
"claude:messages",
"event: error\n",
"\"code\":\"unsupported_finish_reason\"",
),
(
"gemini:generate_content",
"data: {\"error\":",
"\"status\":\"INTERNAL\"",
),
];
for (client_api_format, prefix, marker) in cases {
let report_context = report_context("claude:messages", client_api_format);
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(
&report_context,
data_line(json!({
"type": "message_delta",
"delta": {
"stop_reason": "future_reason"
},
"usage": {
"input_tokens": 1,
"output_tokens": 2
}
})),
)
.expect("unknown Claude stop reason should fail closed visibly");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.contains(prefix), "{client_api_format}: {sse}");
assert!(
sse.contains("Unsupported provider stream finish reason"),
"{client_api_format}: {sse}"
);
assert!(sse.contains("future_reason"), "{client_api_format}: {sse}");
assert!(sse.contains(marker), "{client_api_format}: {sse}");
assert!(matrix
.finish(&report_context)
.expect("finish should succeed")
.is_empty());
}
}
#[test]
fn openai_responses_client_emits_incomplete_for_length_finish_reason() {
let report_context = report_context("openai:chat", "openai:responses");
let mut matrix = StreamingStandardFormatMatrix::default();
let output = matrix
.transform_line(
&report_context,
data_line(json!({
"id": "chatcmpl_length_finish",
"object": "chat.completion.chunk",
"model": "gpt-5.4",
"choices": [{
"index": 0,
"delta": {},
"finish_reason": "length"
}],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 2,
"total_tokens": 3
}
})),
)
.expect("length finish reason should map to response.incomplete");
let sse = String::from_utf8(output).expect("sse should be utf8");
assert!(sse.contains("event: response.incomplete\n"));
assert!(sse.contains("\"status\":\"incomplete\""));
assert!(sse.contains("\"incomplete_details\":{\"reason\":\"max_output_tokens\"}"));
assert!(!sse.contains("event: response.completed\n"));
}
#[test]
fn rewrites_gemini_inline_image_streams_to_claude_image_blocks() {
let report_context = report_context("gemini:generate_content", "claude:messages");
@@ -972,6 +1513,8 @@ mod tests {
"object": "response",
"model": "gpt-5.5",
"status": "completed",
"error": null,
"incomplete_details": null,
"output": [],
"usage": {
"input_tokens": 26,
@@ -994,6 +1537,8 @@ mod tests {
.latest_summary()
.cloned()
.expect("summary should exist");
assert!(summary.observed_finish);
assert_eq!(summary.parser_error, None);
let usage = summary
.standardized_usage
.expect("standardized usage should exist");
@@ -1098,6 +1643,45 @@ mod tests {
assert_eq!(summary.unknown_event_count, 1);
}
#[test]
fn terminal_observer_marks_openai_responses_incomplete_as_length_finish() {
let mut report_context = report_context("openai:chat", "openai:responses");
report_context["provider_stream_event_api_format"] = json!("openai:responses");
let mut observer = StreamingStandardTerminalObserver::default();
observer
.push_line(
&report_context,
data_line(json!({
"type": "response.incomplete",
"response": {
"id": "resp_incomplete_123",
"model": "gpt-5.4",
"status": "incomplete",
"incomplete_details": {
"reason": "max_output_tokens",
},
"output": [],
"usage": {
"input_tokens": 10,
"output_tokens": 20,
"total_tokens": 30,
},
},
})),
)
.expect("incomplete event should be observed as terminal finish");
let summary = observer
.latest_summary()
.cloned()
.expect("summary should exist");
assert!(summary.observed_finish);
assert_eq!(summary.finish_reason.as_deref(), Some("length"));
assert_eq!(summary.parser_error, None);
assert_eq!(summary.unknown_event_count, 0);
}
#[test]
fn terminal_observer_tracks_openai_image_stream_usage() {
let mut report_context = report_context("openai:image", "openai:chat");
@@ -8,7 +8,7 @@ use aether_ai_formats::formats::conversion::response::{
convert_openai_responses_response_to_openai_chat,
};
use aether_ai_formats::formats::openai::responses::response::ensure_modern_openai_responses_response_fields;
use aether_ai_formats::formats::registry::{convert_response, FormatContext};
use aether_ai_formats::formats::registry::{convert_response, FormatContext, FormatError};
use aether_ai_formats::{
canonical_to_claude_response, canonical_to_embedding_response, canonical_to_gemini_response,
canonical_to_openai_chat_response, canonical_to_openai_responses_compact_response,
@@ -20,7 +20,9 @@ use aether_ai_formats::{
use serde_json::{json, Map, Value};
use super::AiSurfaceFinalizeError;
use crate::formats::claude::messages::stream::ClaudeProviderState;
use crate::formats::gemini::generate_content::stream::GeminiProviderState;
use crate::formats::openai::chat::stream::{OpenAIChatProviderState, OpenAIResponsesProviderState};
use crate::formats::shared::model_directives::model_directive_display_model_from_report_context;
use crate::formats::shared::response::{
remove_empty_pages_from_tool_arguments, remove_empty_pages_from_tool_input_value,
@@ -28,8 +30,9 @@ use crate::formats::shared::response::{
};
use crate::formats::shared::stream_core::common::{
content_part_from_openai_image_generation_item, gemini_usage_metadata_from_usage,
map_openai_finish_reason_to_gemini, parse_json_arguments_value, CanonicalContentPart,
CanonicalStreamEvent, CanonicalUsage,
map_openai_finish_reason_to_gemini, parse_json_arguments_value,
unsupported_stream_event_message, CanonicalContentPart, CanonicalStreamEvent,
CanonicalStreamFrame, CanonicalUsage,
};
#[derive(Clone, Debug, PartialEq)]
@@ -71,9 +74,9 @@ pub fn maybe_build_standard_cross_format_sync_product_from_normalized_payload(
Some(body_base64) => {
let body_bytes = base64::engine::general_purpose::STANDARD.decode(body_base64)?;
if is_standard_chat_finalize_kind(report_kind) {
aggregate_standard_chat_stream_sync_response(&body_bytes, provider_api_format)
try_aggregate_standard_chat_stream_sync_response(&body_bytes, provider_api_format)?
} else if is_standard_cli_finalize_kind(report_kind) {
aggregate_standard_cli_stream_sync_response(&body_bytes, provider_api_format)
try_aggregate_standard_cli_stream_sync_response(&body_bytes, provider_api_format)?
} else {
return Ok(None);
}
@@ -545,9 +548,9 @@ fn maybe_build_standard_same_format_stream_sync_body(
};
let body_bytes = base64::engine::general_purpose::STANDARD.decode(body_base64)?;
Ok(
aggregate_same_format_stream_sync_response(expected_api_format, &body_bytes).map(|body| {
client_body_with_report_context_model(body, report_context, &client_api_format)
}),
try_aggregate_same_format_stream_sync_response(expected_api_format, &body_bytes)?.map(
|body| client_body_with_report_context_model(body, report_context, &client_api_format),
),
)
}
@@ -647,7 +650,7 @@ fn maybe_build_openai_responses_same_family_stream_sync_body(
};
let body_bytes = base64::engine::general_purpose::STANDARD.decode(body_base64)?;
Ok(
aggregate_openai_responses_stream_sync_response(&body_bytes).map(|body| {
try_aggregate_openai_responses_stream_sync_response(&body_bytes)?.map(|body| {
client_body_with_report_context_model(body, report_context, &client_api_format)
}),
)
@@ -664,10 +667,12 @@ fn maybe_build_openai_cross_format_provider_body_from_normalized_payload(
let normalized_provider_api_format =
normalize_openai_responses_family_api_format(provider_api_format);
match normalized_provider_api_format.as_str() {
"claude:messages" => aggregate_claude_stream_sync_response(&body_bytes),
"gemini:generate_content" => aggregate_gemini_stream_sync_response(&body_bytes),
"claude:messages" => try_aggregate_claude_stream_sync_response(&body_bytes)?,
"gemini:generate_content" => {
try_aggregate_gemini_stream_sync_response(&body_bytes)?
}
"openai:responses" | "openai:responses:compact" => {
aggregate_openai_responses_stream_sync_response(&body_bytes)
try_aggregate_openai_responses_stream_sync_response(&body_bytes)?
}
_ => None,
}
@@ -760,14 +765,23 @@ pub fn aggregate_standard_chat_stream_sync_response(
body: &[u8],
provider_api_format: &str,
) -> Option<Value> {
try_aggregate_standard_chat_stream_sync_response(body, provider_api_format)
.ok()
.flatten()
}
fn try_aggregate_standard_chat_stream_sync_response(
body: &[u8],
provider_api_format: &str,
) -> Result<Option<Value>, AiSurfaceFinalizeError> {
match aether_ai_formats::normalize_api_format_alias(provider_api_format).as_str() {
"openai:chat" => aggregate_openai_chat_stream_sync_response(body),
"openai:chat" => try_aggregate_openai_chat_stream_sync_response(body),
"openai:responses" | "openai:responses:compact" => {
aggregate_openai_responses_stream_sync_response(body)
try_aggregate_openai_responses_stream_sync_response(body)
}
"claude:messages" => aggregate_claude_stream_sync_response(body),
"gemini:generate_content" => aggregate_gemini_stream_sync_response(body),
_ => None,
"claude:messages" => try_aggregate_claude_stream_sync_response(body),
"gemini:generate_content" => try_aggregate_gemini_stream_sync_response(body),
_ => Ok(None),
}
}
@@ -777,13 +791,15 @@ pub fn convert_standard_chat_response(
client_api_format: &str,
report_context: &Value,
) -> Option<Value> {
if let Ok(converted) = convert_response(
match convert_response(
provider_api_format,
client_api_format,
body_json,
&format_context_from_report_context(report_context),
) {
return Some(converted);
Ok(converted) => return Some(converted),
Err(error) if response_conversion_error_requires_fail_closed(&error) => return None,
Err(_) => {}
}
if matches!(
@@ -836,19 +852,28 @@ pub fn aggregate_standard_cli_stream_sync_response(
aggregate_standard_chat_stream_sync_response(body, provider_api_format)
}
fn try_aggregate_standard_cli_stream_sync_response(
body: &[u8],
provider_api_format: &str,
) -> Result<Option<Value>, AiSurfaceFinalizeError> {
try_aggregate_standard_chat_stream_sync_response(body, provider_api_format)
}
pub fn convert_standard_cli_response(
body_json: &Value,
provider_api_format: &str,
client_api_format: &str,
report_context: &Value,
) -> Option<Value> {
if let Ok(converted) = convert_response(
match convert_response(
provider_api_format,
client_api_format,
body_json,
&format_context_from_report_context(report_context),
) {
return Some(converted);
Ok(converted) => return Some(converted),
Err(error) if response_conversion_error_requires_fail_closed(&error) => return None,
Err(_) => {}
}
if matches!(
@@ -908,6 +933,17 @@ pub fn convert_standard_cli_response(
}
}
fn response_conversion_error_requires_fail_closed(error: &FormatError) -> bool {
matches!(
error,
FormatError::UnsupportedField { .. }
| FormatError::UnauditedField { .. }
| FormatError::InvalidEnumValue { .. }
| FormatError::LossyConversionBlocked { .. }
| FormatError::InvalidTargetField { .. }
)
}
fn format_context_from_report_context(report_context: &Value) -> FormatContext {
let mut context = FormatContext::default().with_report_context(report_context.clone());
if let Some(mapped_model) = report_context
@@ -1428,12 +1464,15 @@ fn standard_same_format_api_format(report_kind: &str) -> Option<&'static str> {
}
}
fn aggregate_same_format_stream_sync_response(api_format: &str, body: &[u8]) -> Option<Value> {
fn try_aggregate_same_format_stream_sync_response(
api_format: &str,
body: &[u8],
) -> Result<Option<Value>, AiSurfaceFinalizeError> {
match api_format {
"openai:chat" => aggregate_openai_chat_stream_sync_response(body),
"claude:messages" => aggregate_claude_stream_sync_response(body),
"gemini:generate_content" => aggregate_gemini_stream_sync_response(body),
_ => None,
"openai:chat" => try_aggregate_openai_chat_stream_sync_response(body),
"claude:messages" => try_aggregate_claude_stream_sync_response(body),
"gemini:generate_content" => try_aggregate_gemini_stream_sync_response(body),
_ => Ok(None),
}
}
@@ -1503,6 +1542,58 @@ fn parse_stream_json_events(body: &[u8]) -> Option<Vec<Value>> {
Some(events)
}
fn try_aggregate_openai_chat_stream_sync_response(
body: &[u8],
) -> Result<Option<Value>, AiSurfaceFinalizeError> {
let report_context = Value::Object(Map::new());
let mut provider = OpenAIChatProviderState::default();
ensure_no_unknown_provider_stream_events(body, |line| {
provider.push_line(&report_context, line)
})?;
Ok(aggregate_openai_chat_stream_sync_response(body))
}
fn try_aggregate_openai_responses_stream_sync_response(
body: &[u8],
) -> Result<Option<Value>, AiSurfaceFinalizeError> {
let report_context = Value::Object(Map::new());
let mut provider = OpenAIResponsesProviderState::default();
ensure_no_unknown_provider_stream_events(body, |line| {
provider.push_line(&report_context, line)
})?;
Ok(aggregate_openai_responses_stream_sync_response(body))
}
fn try_aggregate_claude_stream_sync_response(
body: &[u8],
) -> Result<Option<Value>, AiSurfaceFinalizeError> {
let report_context = Value::Object(Map::new());
let mut provider = ClaudeProviderState::default();
ensure_no_unknown_provider_stream_events(body, |line| {
provider.push_line(&report_context, line)
})?;
Ok(aggregate_claude_stream_sync_response(body))
}
fn ensure_no_unknown_provider_stream_events(
body: &[u8],
mut push_line: impl FnMut(Vec<u8>) -> Result<Vec<CanonicalStreamFrame>, AiSurfaceFinalizeError>,
) -> Result<(), AiSurfaceFinalizeError> {
let Ok(text) = std::str::from_utf8(body) else {
return Ok(());
};
for raw_line in text.lines() {
let frames = push_line(raw_line.as_bytes().to_vec())?;
if let Some(payload) = frames.iter().find_map(|frame| match &frame.event {
CanonicalStreamEvent::UnknownEvent(payload) => Some(payload),
_ => None,
}) {
return Err(unsupported_stream_event_finalize_error(payload));
}
}
Ok(())
}
pub fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option<Value> {
let text = std::str::from_utf8(body).ok()?;
let mut response_id: Option<String> = None;
@@ -2792,9 +2883,19 @@ pub fn aggregate_claude_stream_sync_response(body: &[u8]) -> Option<Value> {
}
pub fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
let events = parse_stream_json_events(body)?;
try_aggregate_gemini_stream_sync_response(body)
.ok()
.flatten()
}
fn try_aggregate_gemini_stream_sync_response(
body: &[u8],
) -> Result<Option<Value>, AiSurfaceFinalizeError> {
let Some(events) = parse_stream_json_events(body) else {
return Ok(None);
};
if events.is_empty() {
return None;
return Ok(None);
}
let report_context = Value::Object(Map::new());
@@ -2813,7 +2914,9 @@ pub fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
let mut usage_from_frames: Option<CanonicalUsage> = None;
for event in &events {
let raw_event_object = event.as_object()?;
let Some(raw_event_object) = event.as_object() else {
return Ok(None);
};
if let Some(id) = raw_event_object.get("responseId") {
response_id = Some(id.clone());
}
@@ -2866,7 +2969,7 @@ pub fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
}
let line = format!("data: {event}\n").into_bytes();
let frames = provider.push_line(&report_context, line).ok()?;
let frames = provider.push_line(&report_context, line)?;
for frame in frames {
if response_id.is_none() && !frame.id.is_empty() {
response_id = Some(Value::String(frame.id.clone()));
@@ -2944,7 +3047,9 @@ pub fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
content,
));
}
CanonicalStreamEvent::UnknownEvent(_) => {}
CanonicalStreamEvent::UnknownEvent(payload) => {
return Err(unsupported_stream_event_finalize_error(&payload))
}
CanonicalStreamEvent::ReasoningSummaryDone => {}
CanonicalStreamEvent::Finish {
finish_reason: frame_finish_reason,
@@ -2963,7 +3068,7 @@ pub fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
}
}
let frames = provider.finish(&report_context).ok()?;
let frames = provider.finish(&report_context)?;
for frame in frames {
if response_id.is_none() && !frame.id.is_empty() {
response_id = Some(Value::String(frame.id.clone()));
@@ -2971,22 +3076,29 @@ pub fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
if model_version.is_none() && !frame.model.is_empty() {
model_version = Some(Value::String(frame.model.clone()));
}
if let CanonicalStreamEvent::Finish {
finish_reason: frame_finish_reason,
usage,
} = frame.event
{
finish_reason = frame_finish_reason
.map(|value| map_openai_finish_reason_to_gemini(Some(value.as_str())).to_string())
.or(finish_reason);
if usage.is_some() {
usage_from_frames = usage;
match frame.event {
CanonicalStreamEvent::UnknownEvent(payload) => {
return Err(unsupported_stream_event_finalize_error(&payload))
}
CanonicalStreamEvent::Finish {
finish_reason: frame_finish_reason,
usage,
} => {
finish_reason = frame_finish_reason
.map(|value| {
map_openai_finish_reason_to_gemini(Some(value.as_str())).to_string()
})
.or(finish_reason);
if usage.is_some() {
usage_from_frames = usage;
}
}
_ => {}
}
}
if !saw_candidate {
return None;
return Ok(None);
}
candidate.insert(
@@ -3026,7 +3138,11 @@ pub fn aggregate_gemini_stream_sync_response(body: &[u8]) -> Option<Value> {
if let Some(prompt) = prompt_feedback {
response.insert("promptFeedback".to_string(), prompt);
}
Some(Value::Object(response))
Ok(Some(Value::Object(response)))
}
fn unsupported_stream_event_finalize_error(payload: &Value) -> AiSurfaceFinalizeError {
AiSurfaceFinalizeError::new(unsupported_stream_event_message(payload))
}
fn append_gemini_text_part(parts: &mut Vec<Value>, text: String, thought: bool) {
@@ -3254,6 +3370,7 @@ fn guess_media_type_from_reference(reference: &str, default_mime: &str) -> Strin
mod tests {
use super::{
aggregate_claude_stream_sync_response, aggregate_gemini_stream_sync_response,
aggregate_openai_chat_stream_sync_response,
aggregate_openai_responses_stream_sync_response, convert_standard_chat_response,
convert_standard_cli_response,
maybe_build_openai_chat_cross_format_sync_product_from_normalized_payload,
@@ -3275,6 +3392,41 @@ mod tests {
use base64::Engine as _;
use serde_json::json;
#[test]
fn aggregates_openai_chat_stream_tool_usage_and_finish_into_sync_body() {
let body = concat!(
"data: {\"id\":\"chatcmpl_stream_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello \"}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_123\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"q\\\"\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\":\\\"rust\\\"}\"}}]}}]}\n\n",
"data: {\"id\":\"chatcmpl_stream_123\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n",
);
let result = aggregate_openai_chat_stream_sync_response(body.as_bytes())
.expect("openai chat stream should aggregate into a sync body");
assert_eq!(result["id"], "chatcmpl_stream_123");
assert_eq!(result["model"], "gpt-5");
assert_eq!(result["choices"][0]["message"]["role"], "assistant");
assert_eq!(result["choices"][0]["message"]["content"], "Hello ");
assert_eq!(
result["choices"][0]["message"]["tool_calls"][0]["id"],
"call_123"
);
assert_eq!(
result["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
"lookup"
);
assert_eq!(
result["choices"][0]["message"]["tool_calls"][0]["function"]["arguments"],
"{\"q\":\"rust\"}"
);
assert_eq!(result["choices"][0]["finish_reason"], "tool_calls");
assert_eq!(result["usage"]["prompt_tokens"], 1);
assert_eq!(result["usage"]["completion_tokens"], 2);
assert_eq!(result["usage"]["total_tokens"], 3);
}
#[test]
fn aggregates_claude_stream_thinking_signatures_into_sync_body() {
let body = concat!(
@@ -3413,6 +3565,55 @@ mod tests {
assert_eq!(aggregated["usageMetadata"]["totalTokenCount"], 5);
}
#[test]
fn gemini_stream_aggregation_rejects_unknown_parts() {
let body = "data: {\"responseId\":\"resp_gem_unknown_123\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"index\":0,\"content\":{\"role\":\"model\",\"parts\":[{\"futurePart\":{\"kept\":true}}]}}]}\n\n";
assert!(
aggregate_gemini_stream_sync_response(body.as_bytes()).is_none(),
"unknown Gemini stream parts must not be silently aggregated into a successful sync body"
);
}
#[test]
fn gemini_stream_finalize_rejects_unknown_parts_even_with_json_fallback() {
let report_context = json!({
"provider_api_format": "gemini:generate_content",
"client_api_format": "openai:chat",
});
let stream_body = "data: {\"responseId\":\"resp_gem_unknown_456\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"index\":0,\"content\":{\"role\":\"model\",\"parts\":[{\"futurePart\":{\"kept\":true}}]}}]}\n\n";
let provider_body_json = json!({
"responseId": "resp_gem_unknown_456",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"index": 0,
"content": {
"role": "model",
"parts": [{
"text": "fallback"
}]
}
}]
});
let result = maybe_build_standard_cross_format_sync_product_from_normalized_payload(
"openai_chat_sync_finalize",
200,
Some(&report_context),
Some(&provider_body_json),
Some(&base64::engine::general_purpose::STANDARD.encode(stream_body)),
);
assert!(result.is_err());
let error = result.expect_err("unknown Gemini stream parts should fail closed");
assert!(error
.to_string()
.contains("Unsupported provider stream event cannot be converted losslessly"));
assert!(error
.to_string()
.contains("field $.futurePart is unsupported"));
}
#[test]
fn aggregates_gemini_stream_function_response_into_sync_body() {
let body = concat!(
@@ -4812,6 +5013,180 @@ mod tests {
assert!(product.is_none());
}
#[test]
fn strict_response_conversion_errors_do_not_use_legacy_fallback() {
let openai_context = json!({
"provider_api_format": "openai:chat",
"client_api_format": "claude:messages",
"model": "claude-sonnet-4-5",
"mapped_model": "gpt-5",
});
let openai_body = json!({
"id": "chatcmpl_unknown_finish",
"object": "chat.completion",
"model": "gpt-5",
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": "done"
},
"finish_reason": "future_reason"
}]
});
assert!(convert_standard_chat_response(
&openai_body,
"openai:chat",
"claude:messages",
&openai_context,
)
.is_none());
let claude_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "openai:chat",
"model": "gpt-5",
"mapped_model": "claude-sonnet-4-5",
});
let claude_body = json!({
"id": "msg_unknown_stop",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [],
"stop_reason": "future_reason",
"usage": {
"input_tokens": 1,
"output_tokens": 2
}
});
assert!(convert_standard_chat_response(
&claude_body,
"claude:messages",
"openai:chat",
&claude_context,
)
.is_none());
}
#[test]
fn stream_finalize_rejects_unknown_openai_chat_events_without_body_fallback() {
let report_context = json!({
"provider_api_format": "openai:chat",
"client_api_format": "claude:messages",
"model": "claude-sonnet-4-5",
"mapped_model": "gpt-5",
});
let stream_body = "data: {\"id\":\"chatcmpl_unknown_stream\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"future_delta\":\"x\"}}]}\n\n";
let fallback_body = json!({
"id": "chatcmpl_fallback",
"object": "chat.completion",
"model": "gpt-5",
"choices": [{
"index": 0,
"message": {"role": "assistant", "content": "fallback"},
"finish_reason": "stop"
}]
});
let error = maybe_build_standard_cross_format_sync_product_from_normalized_payload(
"claude_chat_sync_finalize",
200,
Some(&report_context),
Some(&fallback_body),
Some(&base64::engine::general_purpose::STANDARD.encode(stream_body)),
)
.expect_err("unknown stream event should fail closed before body fallback");
assert!(error
.to_string()
.contains("Unsupported provider stream event cannot be converted losslessly"));
}
#[test]
fn stream_finalize_rejects_unknown_claude_events_without_body_fallback() {
let report_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "openai:chat",
"model": "gpt-5",
"mapped_model": "claude-sonnet-4-5",
});
let stream_body = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_unknown_stream\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-5\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null}}\n\n",
"event: future_event\n",
"data: {\"type\":\"future_event\",\"payload\":{\"kept\":true}}\n\n",
);
let fallback_body = json!({
"id": "msg_fallback",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-5",
"content": [{"type": "text", "text": "fallback"}],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 1,
"output_tokens": 2
}
});
let error = maybe_build_standard_cross_format_sync_product_from_normalized_payload(
"openai_chat_sync_finalize",
200,
Some(&report_context),
Some(&fallback_body),
Some(&base64::engine::general_purpose::STANDARD.encode(stream_body)),
)
.expect_err("unknown stream event should fail closed before body fallback");
assert!(error
.to_string()
.contains("Unsupported provider stream event cannot be converted losslessly"));
assert!(error
.to_string()
.contains("field $.type = \"future_event\""));
}
#[test]
fn responses_same_family_stream_finalize_rejects_unknown_events_without_body_fallback() {
let report_context = json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
"needs_conversion": false,
"model": "gpt-5",
"mapped_model": "gpt-5",
});
let stream_body = concat!(
"event: response.future.delta\n",
"data: {\"type\":\"response.future.delta\",\"response\":{\"id\":\"resp_unknown_stream\",\"object\":\"response\",\"model\":\"gpt-5\",\"status\":\"in_progress\"},\"payload\":{\"kept\":true}}\n\n",
);
let fallback_body = json!({
"id": "resp_fallback",
"object": "response",
"model": "gpt-5",
"status": "completed",
"output": []
});
let error = maybe_build_openai_responses_same_family_sync_body_from_normalized_payload(
"openai_responses_sync_finalize",
200,
Some(&report_context),
Some(&fallback_body),
Some(&base64::engine::general_purpose::STANDARD.encode(stream_body)),
)
.expect_err("unknown stream event should fail closed before body fallback");
assert!(error
.to_string()
.contains("Unsupported provider stream event cannot be converted losslessly"));
assert!(error
.to_string()
.contains("field $.type = \"response.future.delta\""));
}
#[test]
fn standard_sync_finalize_product_prefers_same_format_success_body() {
let report_context = json!({
+9 -2
View File
@@ -6,7 +6,10 @@ pub mod formats;
pub mod protocol;
pub mod provider_compat;
pub use formats::context::{FormatContext, FormatError};
pub use formats::context::{
ConversionFieldRecord, ConversionFieldStatus, ConversionReport, Converted, FormatContext,
FormatError,
};
pub use formats::id::{
api_format_alias_matches, api_format_storage_aliases, api_format_uses_body_stream_field,
is_openai_responses_compact_format, is_openai_responses_family_format,
@@ -19,7 +22,11 @@ pub use formats::matrix::{
sync_cli_response_conversion_kind, RequestConversionKind, SyncChatResponseConversionKind,
SyncCliResponseConversionKind,
};
pub use formats::registry::{build_stream_transcoder, convert_request, convert_response};
pub use formats::registry::{
build_stream_transcoder, convert_request, convert_request_pure,
convert_request_pure_with_context, convert_response, convert_response_pure, emit_request_pure,
emit_response_pure, parse_request_pure, parse_response_pure,
};
pub use formats::shared::model_directives::{
apply_model_directive_mapping_patch, apply_model_directive_overrides_from_model,
apply_model_directive_overrides_from_request, claude_model_uses_adaptive_effort,
+197 -139
View File
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
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};
@@ -15,6 +16,7 @@ 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 OPENAI_THINKING_SOURCE_MARKER: &str = "openai_thinking";
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_CHAT_TOOL_ERROR_PREFIX: &str = "[tool error]";
@@ -186,6 +188,8 @@ pub struct CanonicalToolDefinition {
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>,
}
@@ -459,8 +463,11 @@ pub fn from_openai_chat_to_canonical_request(body_json: &Value) -> Option<Canoni
crate::formats::openai::chat::request::from_raw(body_json)
}
pub fn canonical_to_openai_chat_request(canonical: &CanonicalRequest) -> Value {
crate::formats::openai::chat::request::to_raw(canonical)
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> {
@@ -1448,6 +1455,7 @@ pub(crate) fn openai_message_content_blocks(
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 {
@@ -1568,6 +1576,7 @@ pub(crate) fn openai_reasoning_blocks(message: &Map<String, Value>) -> Vec<Canon
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
@@ -1585,11 +1594,15 @@ pub(crate) fn openai_reasoning_blocks(message: &Map<String, Value>) -> Vec<Canon
.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: openai_extensions(part_object, &["type", "data"]),
extensions,
});
}
}
@@ -1837,6 +1850,7 @@ fn openai_responses_reasoning_block(text: String) -> CanonicalContentBlock {
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);
CanonicalContentBlock::Thinking {
text,
signature: None,
@@ -1939,6 +1953,11 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
}
"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);
if let Some(summary_items) = item_object.get("summary").and_then(Value::as_array) {
for summary in summary_items {
let Some(summary_object) = summary.as_object() else {
@@ -1957,18 +1976,32 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
);
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: text.to_string(),
signature: None,
encrypted_content: item_object
.get("encrypted_content")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
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", "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,
@@ -2195,7 +2228,7 @@ pub(crate) fn openai_responses_part_to_canonical_block(
);
canonical_extension_object_mut(&mut extensions, "openai")
.insert("omit_reasoning_parts".to_string(), Value::Bool(true));
extensions
openai_thinking_extensions(extensions)
},
}),
"input_image" | "output_image" | "image_url" => {
@@ -2656,6 +2689,14 @@ fn claude_thinking_extensions(mut extensions: BTreeMap<String, Value>) -> BTreeM
extensions
}
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
}
pub(crate) fn is_claude_thinking_block(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)
@@ -2664,6 +2705,14 @@ pub(crate) fn is_claude_thinking_block(extensions: &BTreeMap<String, Value>) ->
== Some(CLAUDE_THINKING_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_claude_tool_result(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)
@@ -2725,16 +2774,16 @@ fn anthropic_tool_result_blocks_to_openai_chat_content(parts: &[Value]) -> Value
}
let mut has_media_part = false;
let converted_parts = parts
.iter()
.map(|part| {
let openai_part = anthropic_tool_result_block_to_openai_chat_part(part);
if !openai_chat_part_is_text(&openai_part) {
has_media_part = true;
}
openai_part
})
.collect::<Vec<_>>();
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)
@@ -2755,34 +2804,22 @@ fn anthropic_text_blocks_to_string(parts: &[Value]) -> Option<String> {
Some(texts.join("\n\n"))
}
fn anthropic_tool_result_block_to_openai_chat_part(part: &Value) -> Value {
let Some(part_object) = part.as_object() else {
return openai_text_part("[Claude tool_result non-text content omitted]");
};
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" => openai_text_part(
"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).unwrap_or_else(|| {
openai_text_part(anthropic_media_block_summary("image", part_object))
}),
"document" => {
anthropic_document_block_to_openai_chat_part(part_object).unwrap_or_else(|| {
openai_text_part(anthropic_media_block_summary("document", part_object))
})
}
"file" => anthropic_document_block_to_openai_chat_part(part_object).unwrap_or_else(|| {
openai_text_part(anthropic_media_block_summary("file", part_object))
}),
"" => openai_text_part("[Claude tool_result object content omitted]"),
raw_type => openai_text_part(format!("[Claude tool_result {raw_type} content omitted]")),
)),
"image" => anthropic_image_block_to_openai_chat_part(part_object),
"document" | "file" => anthropic_document_block_to_openai_chat_part(part_object),
_ => None,
}
}
@@ -2837,23 +2874,11 @@ fn anthropic_document_block_to_openai_chat_part(block: &Map<String, Value>) -> O
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 anthropic_media_block_summary(kind: &str, block: &Map<String, Value>) -> String {
let media_type = block
.get("source")
.and_then(Value::as_object)
.and_then(anthropic_source_media_type);
match media_type {
Some(media_type) if !media_type.trim().is_empty() => {
format!("[Claude tool_result {kind} content omitted: {media_type}]")
}
_ => format!("[Claude tool_result {kind} content omitted]"),
}
}
fn openai_text_part(text: impl Into<String>) -> Value {
json!({
"type": "text",
@@ -3209,10 +3234,18 @@ pub(crate) fn claude_tools_to_canonical(
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parameters: tool_object.get("input_schema").cloned(),
extensions: claude_extensions(
tool_object,
&["type", "name", "description", "input_schema"],
),
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))
@@ -3361,14 +3394,7 @@ pub(crate) fn claude_thinking_to_canonical(
}
pub(crate) fn claude_output_effort_to_openai_reasoning_effort(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"low" => Some("low"),
"medium" => Some("medium"),
"high" => Some("high"),
"xhigh" => Some("xhigh"),
"max" => Some("max"),
_ => None,
}
ReasoningEffort::parse(value).map(ReasoningEffort::as_openai_chat_value)
}
pub(crate) fn canonical_openai_reasoning_effort(
@@ -3416,13 +3442,20 @@ pub(crate) fn gemini_thinking_to_canonical(
.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)
if let Some(reasoning_effort) = budget_tokens
.map(map_thinking_budget_to_openai_reasoning_effort)
.or(thinking_level)
{
extensions.insert(
"openai".to_string(),
@@ -3625,10 +3658,18 @@ pub(crate) fn gemini_tools_to_canonical(value: Option<&Value>) -> Option<GeminiC
.and_then(Value::as_str)
.map(ToOwned::to_owned),
parameters: declaration_object.get("parameters").cloned(),
extensions: gemini_extensions(
declaration_object,
&["name", "description", "parameters"],
),
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
},
});
}
}
@@ -3761,6 +3802,7 @@ pub(crate) fn openai_tools_to_canonical(
.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"]),
})
})
@@ -3792,8 +3834,10 @@ pub(crate) fn openai_responses_tools_to_canonical(
.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"]);
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(),
@@ -3802,6 +3846,7 @@ pub(crate) fn openai_responses_tools_to_canonical(
.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;
@@ -3818,9 +3863,10 @@ pub(crate) fn openai_responses_tools_to_canonical(
.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"],
&["type", "name", "description", "parameters", "strict"],
),
});
} else if tool_type == "custom" {
@@ -3853,6 +3899,7 @@ pub(crate) fn openai_responses_tools_to_canonical(
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(),
@@ -3863,6 +3910,7 @@ pub(crate) fn openai_responses_tools_to_canonical(
name: tool_type,
description: None,
parameters: None,
strict: None,
extensions: BTreeMap::from([(
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
tool.clone(),
@@ -3898,6 +3946,9 @@ pub(crate) fn canonical_tool_to_openai(tool: &CanonicalToolDefinition) -> Value
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),
@@ -4086,7 +4137,11 @@ pub(crate) fn canonical_block_to_claude(
encrypted_content,
extensions,
} => {
if let Some(data) = encrypted_content.as_ref().filter(|value| !value.is_empty()) {
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(),
@@ -4428,9 +4483,18 @@ pub(crate) fn canonical_tools_to_claude(canonical: &CanonicalRequest) -> Vec<Val
}
out.insert(
"input_schema".to_string(),
claude_input_schema_from_tool_parameters(tool.parameters.as_ref()),
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())
}),
);
out.extend(namespace_extension_object(&tool.extensions, "claude", &out));
let mut extra = namespace_extension_object(&tool.extensions, "claude", &out);
extra.remove("raw_input_schema");
out.extend(extra);
Value::Object(out)
})
.collect::<Vec<_>>();
@@ -4544,9 +4608,29 @@ pub(crate) fn apply_gemini_request_extensions(
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")
@@ -4912,9 +4996,15 @@ pub(crate) fn gemini_stop_reason_to_canonical(value: &str) -> Option<CanonicalSt
Some(match value.trim().to_ascii_uppercase().as_str() {
"STOP" => CanonicalStopReason::EndTurn,
"MAX_TOKENS" => CanonicalStopReason::MaxTokens,
"SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII" => {
CanonicalStopReason::ContentFiltered
}
"SAFETY"
| "RECITATION"
| "LANGUAGE"
| "BLOCKLIST"
| "PROHIBITED_CONTENT"
| "SPII"
| "IMAGE_SAFETY"
| "IMAGE_PROHIBITED_CONTENT"
| "IMAGE_RECITATION" => CanonicalStopReason::ContentFiltered,
"OTHER" => CanonicalStopReason::Unknown,
_ => CanonicalStopReason::Unknown,
})
@@ -5154,65 +5244,6 @@ pub(crate) fn gemini_generation_config_extra(
.collect()
}
pub(crate) fn gemini_openai_extra_body(request: &Map<String, Value>) -> Option<Value> {
let mut extra_body = Map::new();
if let Some(generation_config) = request
.get("generationConfig")
.or_else(|| request.get("generation_config"))
.and_then(Value::as_object)
{
let mut google = Map::new();
if let Some(thinking_config) =
gemini_value_by_case(generation_config, "thinkingConfig", "thinking_config").cloned()
{
google.insert("thinking_config".to_string(), thinking_config);
}
if let Some(response_modalities) = gemini_value_by_case(
generation_config,
"responseModalities",
"response_modalities",
)
.cloned()
{
google.insert("response_modalities".to_string(), response_modalities);
}
if !google.is_empty() {
extra_body.insert("google".to_string(), Value::Object(google));
}
let generation_config_extra = gemini_generation_config_extra(generation_config);
if !generation_config_extra.is_empty() {
extra_body.insert(
"gemini".to_string(),
json!({ "generation_config_extra": generation_config_extra }),
);
}
}
if let Some(safety_settings) = request
.get("safetySettings")
.or_else(|| request.get("safety_settings"))
.cloned()
{
let gemini = extra_body
.entry("gemini".to_string())
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()?;
gemini.insert("safety_settings".to_string(), safety_settings);
}
if let Some(cached_content) = request
.get("cachedContent")
.or_else(|| request.get("cached_content"))
.cloned()
{
let gemini = extra_body
.entry("gemini".to_string())
.or_insert_with(|| Value::Object(Map::new()))
.as_object_mut()?;
gemini.insert("cached_content".to_string(), cached_content);
}
(!extra_body.is_empty()).then_some(Value::Object(extra_body))
}
pub(crate) fn extract_gemini_model_from_path(path: &str) -> Option<String> {
let marker = "/models/";
let start = path.find(marker)? + marker.len();
@@ -5733,7 +5764,7 @@ mod tests {
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);
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"], "developer");
@@ -5821,7 +5852,7 @@ mod tests {
"n": 2
});
let canonical = from_openai_chat_to_canonical_request(&request).expect("canonical request");
let rebuilt = canonical_to_openai_chat_request(&canonical);
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")]));
@@ -6316,7 +6347,8 @@ mod tests {
CanonicalContentBlock::ToolUse { ref id, .. } if id == "toolu_auto_0"
));
let openai_chat = canonical_to_openai_chat_request(&canonical);
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"
@@ -6336,6 +6368,32 @@ mod tests {
assert_eq!(rebuilt["output_config"]["effort"], "medium");
}
#[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!({