Merge origin/main into fix/gemini-cli-v1internal

This commit is contained in:
Mas0nShi
2026-05-28 11:58:00 +08:00
410 changed files with 38026 additions and 6621 deletions
+1 -1
View File
@@ -77,7 +77,7 @@ pub use crate::formats::shared::model_directives::{
apply_model_directive_overrides_from_request, claude_model_uses_adaptive_effort,
extract_gemini_model_from_path, gemini_model_uses_thinking_level, model_directive_base_model,
normalize_model_directive_model, parse_model_directive, ModelDirective, ModelOverride,
ReasoningEffort,
ReasoningEffort, ServiceTier,
};
pub use crate::formats::shared::passthrough::{
resolve_stream_spec as resolve_local_same_format_stream_spec,
@@ -410,6 +410,7 @@ enum ClaudeOpenBlock {
struct ClaudeClientToolState {
call_id: String,
name: String,
buffered_arguments: String,
}
#[derive(Default)]
@@ -460,18 +461,47 @@ impl ClaudeClientEmitter {
let Some(open_block) = self.open_block.take() else {
return Ok(Vec::new());
};
let mut out = Vec::new();
let block_index = match open_block {
ClaudeOpenBlock::Text { block_index } => block_index,
ClaudeOpenBlock::Thinking { block_index } => block_index,
ClaudeOpenBlock::Tool { block_index, .. } => block_index,
ClaudeOpenBlock::Tool {
tool_index,
block_index,
} => {
if let Some(state) = self.tool_states.get_mut(&tool_index) {
if state.name == "Read" && !state.buffered_arguments.is_empty() {
let arguments = remove_empty_pages_from_tool_arguments(
&state.name,
&state.buffered_arguments,
);
state.buffered_arguments.clear();
if !arguments.is_empty() {
out.extend(encode_json_sse(
Some("content_block_delta"),
&json!({
"type": "content_block_delta",
"index": block_index,
"delta": {
"type": "input_json_delta",
"partial_json": arguments,
}
}),
)?);
}
}
}
block_index
}
};
encode_json_sse(
out.extend(encode_json_sse(
Some("content_block_stop"),
&json!({
"type": "content_block_stop",
"index": block_index,
}),
)
)?);
Ok(out)
}
fn ensure_text_block(&mut self) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
@@ -688,23 +718,33 @@ impl ClaudeClientEmitter {
Ok(out)
}
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
let arguments = remove_empty_pages_from_tool_arguments(&arguments);
let (call_id, name) = {
let state = self.tool_states.entry(index).or_default();
let call_id = if state.call_id.is_empty() {
format!("tool_{index}")
} else {
state.call_id.clone()
};
let name = if state.name.is_empty() {
"unknown".to_string()
} else {
state.name.clone()
};
(call_id, name)
};
if arguments.is_empty() {
return Ok(Vec::new());
}
let mut out = self.ensure_started()?;
let state = self.tool_states.entry(index).or_default();
let call_id = if state.call_id.is_empty() {
format!("tool_{index}")
} else {
state.call_id.clone()
};
let name = if state.name.is_empty() {
"unknown".to_string()
} else {
state.name.clone()
};
out.extend(self.ensure_tool_block(index, &call_id, &name)?);
if name == "Read" {
self.tool_states
.entry(index)
.or_default()
.buffered_arguments
.push_str(&arguments);
return Ok(out);
}
let block_index = match self.open_block {
Some(ClaudeOpenBlock::Tool { block_index, .. }) => block_index,
_ => return Ok(out),
@@ -1230,7 +1270,7 @@ mod tests {
}
#[test]
fn claude_client_emitter_removes_empty_pages_from_tool_arguments() {
fn claude_client_emitter_removes_empty_pages_from_read_tool_arguments() {
let mut emitter = ClaudeClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
@@ -1257,11 +1297,60 @@ mod tests {
.expect("tool delta should encode"),
);
let pending_sse = String::from_utf8(bytes.clone()).expect("sse should be utf8");
assert!(!pending_sse.contains("\\\"pages\\\":\\\"\\\""));
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "msg_123".to_string(),
model: "claude-sonnet-4-5".to_string(),
event: CanonicalStreamEvent::Finish {
finish_reason: Some("tool_calls".to_string()),
usage: None,
},
})
.expect("finish should close read tool block"),
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("\"partial_json\":\"{\\\"file_path\\\":\\\"/tmp/a.txt\\\",\\\"offset\\\":1,\\\"limit\\\":20}\""));
assert!(!sse.contains("\\\"pages\\\":\\\"\\\""));
}
#[test]
fn claude_client_emitter_preserves_empty_pages_for_other_tool_arguments() {
let mut emitter = ClaudeClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "msg_123".to_string(),
model: "claude-sonnet-4-5".to_string(),
event: CanonicalStreamEvent::ToolCallStart {
index: 0,
call_id: "toolu_search".to_string(),
name: "Search".to_string(),
},
})
.expect("tool start should encode");
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "msg_123".to_string(),
model: "claude-sonnet-4-5".to_string(),
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index: 0,
arguments: r#"{"query":"","pages":""}"#.to_string(),
},
})
.expect("tool delta should encode"),
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(
sse.contains("\"partial_json\":\"{\\\"query\\\":\\\"\\\",\\\"pages\\\":\\\"\\\"}\"")
);
}
#[test]
fn claude_client_emitter_injects_default_usage_into_finish_events() {
let mut emitter = ClaudeClientEmitter::default();
@@ -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_openai_extra_body,
gemini_generation_config_extra, gemini_google_search_grounding, gemini_openai_extra_body,
gemini_response_format_to_canonical, gemini_system_to_canonical_instructions,
gemini_thinking_to_canonical, gemini_tool_choice_to_canonical, gemini_tools_to_canonical,
gemini_value_by_case, CanonicalContentBlock, CanonicalMessage, CanonicalRequest,
@@ -81,7 +81,7 @@ pub fn from_raw(body_json: &Value, request_path: &str) -> Option<CanonicalReques
.get("generationConfig")
.or_else(|| request.get("generation_config")),
);
let (tools, builtin_tools, web_search_options, raw_tools) =
let (tools, builtin_tools, web_search_options, raw_tools, google_search_grounding) =
gemini_tools_to_canonical(request.get("tools"))?;
canonical.tools = tools;
canonical.tool_choice = gemini_tool_choice_to_canonical(
@@ -158,6 +158,13 @@ pub fn from_raw(body_json: &Value, request_path: &str) -> Option<CanonicalReques
canonical_extension_object_mut(&mut canonical.extensions, "gemini")
.insert("builtin_tools".to_string(), Value::Array(builtin_tools));
}
if let Some(google_search_grounding) = google_search_grounding {
let gemini_extension = canonical_extension_object_mut(&mut canonical.extensions, "gemini");
gemini_extension.insert(
"grounding".to_string(),
json!({ "google_search": google_search_grounding }),
);
}
if let Some(tool_config) = request
.get("toolConfig")
.or_else(|| request.get("tool_config"))
@@ -492,6 +499,10 @@ fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option<Value> {
.get("openai")
.and_then(Value::as_object)
.is_some_and(|value| value.contains_key("web_search_options"));
let mut google_search_payload = canonical_google_search_output_payload(canonical);
if google_search_payload.is_some() {
google_search = true;
}
let mut code_execution = false;
let mut url_context = false;
@@ -528,18 +539,9 @@ fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option<Value> {
}
declarations.push(canonical_tool_to_gemini_declaration(tool));
}
if code_execution {
tools.push(json!({ "codeExecution": {} }));
}
if google_search {
tools.push(json!({ "googleSearch": {} }));
}
if url_context {
tools.push(json!({ "urlContext": {} }));
}
if !declarations.is_empty() {
tools.push(json!({ "functionDeclarations": declarations }));
}
let mut emitted_google_search = false;
let mut emitted_code_execution = false;
let mut emitted_url_context = false;
if let Some(builtin_tools) = canonical
.extensions
.get("gemini")
@@ -547,11 +549,124 @@ fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option<Value> {
.and_then(|value| value.get("builtin_tools"))
.and_then(Value::as_array)
{
tools.extend(builtin_tools.iter().cloned());
for builtin_tool in builtin_tools {
let Some(tool_object) = builtin_tool.as_object() else {
tools.push(builtin_tool.clone());
continue;
};
let mut emitted_builtin_portion = false;
if let Some(grounding) = gemini_google_search_grounding(tool_object) {
google_search = true;
if google_search_payload.is_none() {
google_search_payload = Some(grounding.output_payload);
}
if !emitted_google_search {
tools.push(json!({
"googleSearch": google_search_payload.clone().unwrap_or_else(|| json!({}))
}));
emitted_google_search = true;
}
emitted_builtin_portion = true;
}
if let Some(tool) =
gemini_builtin_tool_by_case(tool_object, "codeExecution", "code_execution")
{
if !emitted_code_execution {
tools.push(tool);
emitted_code_execution = true;
}
emitted_builtin_portion = true;
}
if let Some(tool) =
gemini_builtin_tool_by_case(tool_object, "urlContext", "url_context")
{
if !emitted_url_context {
tools.push(tool);
emitted_url_context = true;
}
emitted_builtin_portion = true;
}
if let Some(tool) = gemini_unhandled_builtin_tool_portion(tool_object) {
tools.push(tool);
} else if !emitted_builtin_portion {
tools.push(builtin_tool.clone());
}
}
}
if code_execution && !emitted_code_execution {
tools.push(json!({ "codeExecution": {} }));
}
if google_search && !emitted_google_search {
tools.push(json!({
"googleSearch": google_search_payload.unwrap_or_else(|| json!({}))
}));
}
if url_context && !emitted_url_context {
tools.push(json!({ "urlContext": {} }));
}
if !declarations.is_empty() {
tools.push(json!({ "functionDeclarations": declarations }));
}
(!tools.is_empty()).then_some(Value::Array(tools))
}
fn canonical_google_search_output_payload(canonical: &CanonicalRequest) -> Option<Value> {
let google_search = canonical
.extensions
.get("gemini")
.and_then(Value::as_object)
.and_then(|value| value.get("grounding"))
.and_then(Value::as_object)
.and_then(|value| value.get("google_search"))
.and_then(Value::as_object)?;
google_search
.get("legacy")
.and_then(Value::as_bool)
.filter(|legacy| *legacy)
.map(|_| json!({}))
.or_else(|| google_search.get("payload").cloned())
}
fn gemini_builtin_tool_by_case(
tool_object: &Map<String, Value>,
camel: &str,
snake: &str,
) -> Option<Value> {
let payload = tool_object
.get(camel)
.or_else(|| tool_object.get(snake))
.map(gemini_builtin_tool_payload)?;
Some(json!({ camel: payload }))
}
fn gemini_builtin_tool_payload(payload: &Value) -> Value {
match payload {
Value::Null => json!({}),
value => value.clone(),
}
}
fn gemini_unhandled_builtin_tool_portion(tool_object: &Map<String, Value>) -> Option<Value> {
let builtin = tool_object
.iter()
.filter(|(key, _)| {
!matches!(
key.as_str(),
"googleSearch"
| "google_search"
| "googleSearchRetrieval"
| "google_search_retrieval"
| "codeExecution"
| "code_execution"
| "urlContext"
| "url_context"
)
})
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<Map<_, _>>();
(!builtin.is_empty()).then_some(Value::Object(builtin))
}
fn canonical_tool_to_gemini_declaration(tool: &CanonicalToolDefinition) -> Value {
let mut declaration = Map::new();
declaration.insert("name".to_string(), Value::String(tool.name.clone()));
@@ -63,7 +63,10 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
role: CanonicalRole::Assistant,
content,
stop_reason,
extensions: Default::default(),
extensions: gemini_extensions(
candidate_object,
&["index", "content", "finishReason", "finish_reason"],
),
});
}
outputs.retain(gemini_response_output_has_visible_content);
@@ -161,7 +164,7 @@ fn canonical_to_gemini_response(
let mut candidates = Vec::new();
for output in outputs {
let parts = canonical_blocks_to_gemini_parts(&output.content)?;
candidates.push(json!({
let mut candidate = json!({
"index": output.index,
"content": {
"role": "model",
@@ -170,7 +173,15 @@ fn canonical_to_gemini_response(
"finishReason": canonical_stop_reason_to_gemini(
output.stop_reason.as_ref().or(canonical.stop_reason.as_ref())
),
}));
});
if let Some(candidate_object) = candidate.as_object_mut() {
if let Some(gemini) = output.extensions.get("gemini").and_then(Value::as_object) {
for (key, value) in gemini {
candidate_object.entry(key.clone()).or_insert(value.clone());
}
}
}
candidates.push(candidate);
}
let mut response = Map::new();
@@ -29,6 +29,7 @@ struct OpenAIResponsesProviderToolState {
call_id: String,
name: String,
arguments: String,
emitted_arguments_len: usize,
started_emitted: bool,
}
@@ -427,7 +428,7 @@ impl OpenAIResponsesProviderState {
) {
let missing = if text.starts_with(&self.text) {
text[self.text.len()..].to_string()
} else if self.text == text {
} else if self.text == text || self.text.starts_with(text) {
String::new()
} else {
text.to_string()
@@ -510,6 +511,74 @@ impl OpenAIResponsesProviderState {
});
}
fn emit_ready_tool_call(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
index: usize,
) {
let (id, model) = self.identity(report_context);
let Some(state) = self.tool_calls.get_mut(&index) else {
return;
};
if state.name.is_empty() {
return;
}
if !state.started_emitted {
out.push(CanonicalStreamFrame {
id: id.clone(),
model: model.clone(),
event: CanonicalStreamEvent::ToolCallStart {
index,
call_id: if state.call_id.is_empty() {
build_generated_tool_call_id(index)
} else {
state.call_id.clone()
},
name: state.name.clone(),
},
});
state.started_emitted = true;
}
if state.emitted_arguments_len > state.arguments.len() {
state.emitted_arguments_len = 0;
}
let pending = state
.arguments
.get(state.emitted_arguments_len..)
.unwrap_or_default()
.to_string();
if pending.is_empty() {
return;
}
state.emitted_arguments_len = state.arguments.len();
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index,
arguments: pending,
},
});
}
fn merge_tool_call_arguments(state: &mut OpenAIResponsesProviderToolState, arguments: &str) {
if arguments.is_empty() {
return;
}
if arguments.starts_with(&state.arguments) {
state
.arguments
.push_str(&arguments[state.arguments.len()..]);
} else if state.arguments != arguments {
if state.emitted_arguments_len == 0 {
state.arguments = arguments.to_string();
} else {
state.arguments.push_str(arguments);
}
}
}
fn emit_tool_call_item(
&mut self,
report_context: &Value,
@@ -527,7 +596,6 @@ impl OpenAIResponsesProviderState {
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let index = self.tool_index_for_key(key, output_index);
let (id, model) = self.identity(report_context);
let state = self.tool_calls.entry(index).or_default();
state.call_id = item
.get("call_id")
@@ -540,50 +608,13 @@ impl OpenAIResponsesProviderState {
.and_then(Value::as_str)
.unwrap_or(state.name.as_str())
.to_string();
if !state.started_emitted {
out.push(CanonicalStreamFrame {
id: id.clone(),
model: model.clone(),
event: CanonicalStreamEvent::ToolCallStart {
index,
call_id: if state.call_id.is_empty() {
build_generated_tool_call_id(index)
} else {
state.call_id.clone()
},
name: if state.name.is_empty() {
"unknown".to_string()
} else {
state.name.clone()
},
},
});
state.started_emitted = true;
}
let completed_arguments = item
.get("arguments")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let missing = if completed_arguments.starts_with(&state.arguments) {
completed_arguments[state.arguments.len()..].to_string()
} else if state.arguments == completed_arguments {
String::new()
} else {
completed_arguments.clone()
};
if missing.is_empty() {
return;
}
state.arguments.push_str(&missing);
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index,
arguments: missing,
},
});
Self::merge_tool_call_arguments(state, &completed_arguments);
self.emit_ready_tool_call(report_context, out, index);
}
fn emit_missing_tool_result(
@@ -798,27 +829,24 @@ impl OpenAIResponsesProviderState {
"response.created" | "response.in_progress" => {
self.ensure_started(report_context, &mut out);
}
"response.output_text.delta" | "response.outtext.delta" => {
let piece = match value.get("delta") {
Some(Value::String(text)) => text.clone(),
Some(Value::Object(delta)) => delta
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
_ => String::new(),
};
if !piece.is_empty() {
"response.output_text.delta" | "response.outtext.delta" => match value.get("delta") {
Some(Value::String(piece)) if !piece.is_empty() => {
self.ensure_started(report_context, &mut out);
self.text.push_str(&piece);
self.text.push_str(piece);
let (id, model) = self.identity(report_context);
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::TextDelta(piece),
event: CanonicalStreamEvent::TextDelta(piece.clone()),
});
}
}
Some(Value::Object(delta)) => {
if let Some(text) = delta.get("text").and_then(Value::as_str) {
self.emit_missing_text(report_context, &mut out, text);
}
}
_ => {}
},
"response.content_part.added" | "response.content_part.done" => {
if let Some(part) = value.get("part").and_then(Value::as_object) {
if part.get("type").and_then(Value::as_str) == Some("output_text") {
@@ -978,44 +1006,22 @@ impl OpenAIResponsesProviderState {
.and_then(Value::as_u64)
.map(|value| value as usize);
let index = self.tool_index_for_key(key, output_index);
let (id, model) = self.identity(report_context);
let state = self.tool_calls.entry(index).or_default();
state.call_id = value
.get("item_id")
.or_else(|| value.get("call_id"))
if let Some(call_id) = value
.get("call_id")
.or_else(|| value.get("id"))
.and_then(Value::as_str)
.unwrap_or(state.call_id.as_str())
.to_string();
if !state.started_emitted {
out.push(CanonicalStreamFrame {
id: id.clone(),
model: model.clone(),
event: CanonicalStreamEvent::ToolCallStart {
index,
call_id: if state.call_id.is_empty() {
build_generated_tool_call_id(index)
} else {
state.call_id.clone()
},
name: if state.name.is_empty() {
"unknown".to_string()
} else {
state.name.clone()
},
},
});
state.started_emitted = true;
{
state.call_id = call_id.to_string();
} else if state.call_id.is_empty() {
state.call_id = value
.get("item_id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
}
state.arguments.push_str(delta);
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index,
arguments: delta.to_string(),
},
});
self.emit_ready_tool_call(report_context, &mut out, index);
}
"response.function_call_arguments.done" => {
let arguments = value
@@ -1029,9 +1035,6 @@ impl OpenAIResponsesProviderState {
.and_then(Value::as_str)
})
.unwrap_or_default();
if arguments.is_empty() {
return Ok(out);
}
self.ensure_started(report_context, &mut out);
let key = value
.get("item_id")
@@ -1052,11 +1055,9 @@ impl OpenAIResponsesProviderState {
.and_then(Value::as_u64)
.map(|value| value as usize);
let index = self.tool_index_for_key(key, output_index);
let (id, model) = self.identity(report_context);
let state = self.tool_calls.entry(index).or_default();
state.call_id = value
.get("item_id")
.or_else(|| value.get("call_id"))
.get("call_id")
.or_else(|| value.get("id"))
.and_then(Value::as_str)
.or_else(|| {
@@ -1066,46 +1067,23 @@ impl OpenAIResponsesProviderState {
.and_then(|item| item.get("call_id").or_else(|| item.get("id")))
.and_then(Value::as_str)
})
.or_else(|| value.get("item_id").and_then(Value::as_str))
.unwrap_or(state.call_id.as_str())
.to_string();
if !state.started_emitted {
out.push(CanonicalStreamFrame {
id: id.clone(),
model: model.clone(),
event: CanonicalStreamEvent::ToolCallStart {
index,
call_id: if state.call_id.is_empty() {
build_generated_tool_call_id(index)
} else {
state.call_id.clone()
},
name: if state.name.is_empty() {
"unknown".to_string()
} else {
state.name.clone()
},
},
});
state.started_emitted = true;
}
let missing = if arguments.starts_with(&state.arguments) {
arguments[state.arguments.len()..].to_string()
} else if state.arguments == arguments {
String::new()
} else {
arguments.to_string()
};
if !missing.is_empty() {
state.arguments.push_str(&missing);
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index,
arguments: missing,
},
});
}
state.name = value
.get("name")
.and_then(Value::as_str)
.or_else(|| {
value
.get("item")
.and_then(Value::as_object)
.and_then(|item| item.get("name"))
.and_then(Value::as_str)
})
.unwrap_or(state.name.as_str())
.to_string();
Self::merge_tool_call_arguments(state, arguments);
self.emit_ready_tool_call(report_context, &mut out, index);
}
"response.function_call_output.delta" | "response.function_call_output.done" => {
let tool_use_id = value
@@ -2661,6 +2639,7 @@ fn openai_tool_result_content_from_value(value: Option<&Value>) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::formats::claude::messages::stream::ClaudeClientEmitter;
fn data_line(value: Value) -> Vec<u8> {
format!("data: {}\n", value).into_bytes()
@@ -3176,6 +3155,170 @@ mod tests {
)));
}
#[test]
fn openai_responses_provider_state_does_not_duplicate_text_snapshot_deltas() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let mut frames = Vec::new();
for event in [
json!({
"type": "response.output_text.delta",
"response_id": "resp_snapshot_delta",
"output_index": 0,
"content_index": 0,
"delta": {
"text": "Hello",
}
}),
json!({
"type": "response.output_text.delta",
"response_id": "resp_snapshot_delta",
"output_index": 0,
"content_index": 0,
"delta": {
"text": "Hello world",
}
}),
json!({
"type": "response.output_text.delta",
"response_id": "resp_snapshot_delta",
"output_index": 0,
"content_index": 0,
"delta": {
"text": "Hello",
}
}),
json!({
"type": "response.output_text.done",
"response_id": "resp_snapshot_delta",
"output_index": 0,
"content_index": 0,
"text": "Hello world",
}),
json!({
"type": "response.completed",
"response": {
"id": "resp_snapshot_delta",
"object": "response",
"model": "gpt-5.4",
"status": "completed",
"output": [{
"type": "message",
"id": "msg_snapshot_delta",
"role": "assistant",
"status": "completed",
"content": [{
"type": "output_text",
"text": "Hello world",
"annotations": [],
}]
}],
}
}),
] {
frames.extend(
state
.push_line(&report_context, data_line(event))
.expect("responses text event should parse"),
);
}
let text = frames
.iter()
.filter_map(|frame| match &frame.event {
CanonicalStreamEvent::TextDelta(text) => Some(text.as_str()),
_ => None,
})
.collect::<String>();
assert_eq!(text, "Hello world");
}
#[test]
fn openai_responses_provider_state_delays_arguments_until_tool_name_is_known() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let arguments = r#"{"file_path":"D:/projects/UIAutoTest/docs/prd/msr.md","offset":0,"limit":2000,"pages":""}"#;
let mut frames = Vec::new();
let delta_frames = state
.push_line(
&report_context,
data_line(json!({
"type": "response.function_call_arguments.delta",
"response_id": "resp_read_123",
"output_index": 0,
"item_id": "fc_read_123",
"delta": arguments,
})),
)
.expect("arguments delta should parse");
assert!(matches!(
delta_frames.first().map(|frame| &frame.event),
Some(CanonicalStreamEvent::Start)
));
assert!(!delta_frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart { .. }
| CanonicalStreamEvent::ToolCallArgumentsDelta { .. }
)));
frames.extend(delta_frames);
frames.extend(
state
.push_line(
&report_context,
data_line(json!({
"type": "response.function_call_arguments.done",
"response_id": "resp_read_123",
"output_index": 0,
"item_id": "fc_read_123",
"item": {
"type": "function_call",
"id": "fc_read_123",
"call_id": "call_read_123",
"name": "Read",
"arguments": arguments,
}
})),
)
.expect("arguments done should parse"),
);
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart {
ref call_id,
ref name,
..
} if call_id == "call_read_123" && name == "Read"
)));
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallArgumentsDelta {
ref arguments,
..
} if arguments.contains(r#""pages":"""#)
)));
let mut emitter = ClaudeClientEmitter::default();
let mut bytes = Vec::new();
for frame in frames {
bytes.extend(emitter.emit(frame).expect("claude frame should encode"));
}
bytes.extend(
emitter
.finish()
.expect("claude stream finish should encode"),
);
let sse = String::from_utf8(bytes).expect("claude sse should be utf8");
assert!(sse.contains("\"name\":\"Read\""));
assert!(sse.contains("\\\"limit\\\":2000"));
assert!(!sse.contains("\\\"pages\\\":\\\"\\\""));
}
#[test]
fn openai_responses_provider_state_parses_function_call_output_as_tool_result() {
let mut state = OpenAIResponsesProviderState::default();
@@ -206,6 +206,21 @@ pub fn build_chatgpt_web_image_request_body(
if let Some(user) = request.user.as_ref() {
body.insert("user".to_string(), Value::String(user.clone()));
}
if let Some(quality) = request
.tool
.get("quality")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
body.insert("quality".to_string(), Value::String(quality.to_string()));
}
if let Some(partial_images) = request.tool.get("partial_images").and_then(Value::as_u64) {
body.insert(
"partial_images".to_string(),
Value::Number(Number::from(partial_images)),
);
}
if let Some(output_format) = request
.summary_json
.get("output_format")
@@ -1593,6 +1608,28 @@ mod tests {
assert_eq!(by_size["size"], "1024x1024");
}
#[test]
fn chatgpt_web_preserves_quality_and_partial_images() {
let parts = request_parts("/v1/images/generations", Some("application/json"));
let body = build_chatgpt_web_image_request_body(
&parts,
&json!({
"model": "gpt-image-2",
"prompt": "draw",
"size": "1024x1024",
"quality": "high",
"partial_images": 2,
"output_format": "png"
}),
None,
)
.expect("request should pass");
assert_eq!(body["quality"], "high");
assert_eq!(body["partial_images"], 2);
assert_eq!(body["output_format"], "png");
}
#[test]
fn chatgpt_web_rejects_oversized_resolution_or_size() {
let parts = request_parts("/v1/images/generations", Some("application/json"));
@@ -12,6 +12,20 @@ const CODEX_DEFAULT_INSTRUCTIONS: &str = "";
const CODEX_DEFAULT_REASONING_EFFORT: &str = "medium";
const CODEX_DEFAULT_REASONING_SUMMARY: &str = "auto";
const CODEX_REASONING_ENCRYPTED_CONTENT_INCLUDE: &str = "reasoning.encrypted_content";
const CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS: &[&str] = &[
"max_output_tokens",
"max_completion_tokens",
"temperature",
"top_p",
"frequency_penalty",
"presence_penalty",
"user",
"metadata",
"prompt_cache_retention",
"safety_identifier",
"stream_options",
"previous_response_id",
];
const CODEX_DEFAULT_USER_AGENT: &str =
"codex-tui/0.122.0 (Mac OS 15.2.0; arm64) vscode/2.6.11 (codex-tui; 0.122.0)";
const CODEX_DEFAULT_ORIGINATOR: &str = "codex-tui";
@@ -729,17 +743,10 @@ pub fn apply_codex_openai_responses_special_body_edits(
return;
};
if !body_rules_handle_path(body_rules, "max_output_tokens") {
body_object.remove("max_output_tokens");
}
if !body_rules_handle_path(body_rules, "temperature") {
body_object.remove("temperature");
}
if !body_rules_handle_path(body_rules, "top_p") {
body_object.remove("top_p");
}
if !body_rules_handle_path(body_rules, "metadata") {
body_object.remove("metadata");
for field in CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS {
if !body_rules_handle_path(body_rules, field) {
body_object.remove(*field);
}
}
if is_openai_responses_compact_request(provider_api_format) {
body_object.remove("store");
@@ -881,6 +888,7 @@ mod tests {
apply_codex_openai_responses_chat_body_edits,
apply_codex_openai_responses_special_body_edits,
apply_openai_responses_compact_special_body_edits, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS,
};
use serde_json::json;
@@ -1009,6 +1017,42 @@ mod tests {
assert!(provider_request_body["tools"][0].get("function").is_none());
}
#[test]
fn codex_responses_body_edits_strip_sub2api_unsupported_fields() {
let mut provider_request_body = json!({
"input": [{"role": "user", "content": "hello"}],
"model": "gpt-5.4",
"max_output_tokens": 1024,
"max_completion_tokens": 1024,
"temperature": 0.2,
"top_p": 0.8,
"frequency_penalty": 0.1,
"presence_penalty": 0.1,
"user": "user-123",
"metadata": {"client": "cursor"},
"prompt_cache_retention": "24h",
"safety_identifier": "safe-user-123",
"stream_options": {"include_usage": true},
"previous_response_id": "resp_123"
});
apply_codex_openai_responses_special_body_edits(
&mut provider_request_body,
"codex",
"openai:responses",
None,
None,
);
for field in CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS {
assert!(
provider_request_body.get(*field).is_none(),
"{field} must be stripped"
);
}
assert_eq!(provider_request_body["input"][0]["content"], json!("hello"));
}
#[test]
fn codex_responses_body_edits_strip_name_from_hosted_web_search_tool() {
let mut provider_request_body = json!({
@@ -130,13 +130,13 @@ pub fn to_raw(
let mut output = Map::new();
output.insert("model".to_string(), Value::String(mapped_model.to_string()));
if let Some(instructions) = canonical_instructions_to_responses(canonical) {
let instructions = canonical_instructions_to_responses(canonical);
if let Some(instructions) = instructions.clone() {
output.insert("instructions".to_string(), instructions);
}
output.insert(
"input".to_string(),
Value::Array(canonical_messages_to_responses_input(canonical)?),
);
let mut input = canonical_messages_to_responses_input(canonical)?;
ensure_json_object_response_input_mentions_json(canonical, instructions.as_ref(), &mut input);
output.insert("input".to_string(), Value::Array(input));
if upstream_is_stream && !compact {
output.insert("stream".to_string(), Value::Bool(true));
@@ -265,6 +265,42 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
Some(input)
}
fn ensure_json_object_response_input_mentions_json(
canonical: &CanonicalRequest,
instructions: Option<&Value>,
input: &mut Vec<Value>,
) {
if !canonical
.response_format
.as_ref()
.is_some_and(|format| format.format_type.eq_ignore_ascii_case("json_object"))
|| input.iter().any(value_contains_json_word)
|| !instructions.is_some_and(value_contains_json_word)
{
return;
}
input.insert(
0,
json!({
"type": "message",
"role": "system",
"content": [{
"type": "input_text",
"text": "Respond with JSON.",
}],
}),
);
}
fn value_contains_json_word(value: &Value) -> bool {
match value {
Value::String(text) => text.to_ascii_lowercase().contains("json"),
Value::Array(items) => items.iter().any(value_contains_json_word),
Value::Object(object) => object.values().any(value_contains_json_word),
_ => false,
}
}
fn flush_responses_message(input: &mut Vec<Value>, role: &str, content: &mut Vec<Value>) {
if content.is_empty() {
return;
@@ -514,3 +550,45 @@ fn insert_number(output: &mut Map<String, Value>, key: &str, value: Option<f64>)
output.insert(key.to_string(), Value::Number(value));
}
}
#[cfg(test)]
mod tests {
use super::to_raw;
use crate::protocol::canonical::{
CanonicalContentBlock, CanonicalMessage, CanonicalRequest, CanonicalResponseFormat,
CanonicalRole,
};
use serde_json::json;
#[test]
fn json_object_response_injects_json_hint_into_input_when_only_instructions_have_it() {
let request = CanonicalRequest {
model: "gpt-5.5".to_string(),
system: Some("Please answer in JSON.".to_string()),
messages: vec![CanonicalMessage {
role: CanonicalRole::User,
content: vec![CanonicalContentBlock::Text {
text: "hello".to_string(),
extensions: Default::default(),
}],
extensions: Default::default(),
}],
response_format: Some(CanonicalResponseFormat {
format_type: "json_object".to_string(),
json_schema: None,
extensions: Default::default(),
}),
..CanonicalRequest::default()
};
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"]
.as_str()
.expect("hint text")
.to_ascii_lowercase()
.contains("json"));
}
}
@@ -9,6 +9,7 @@ pub struct ModelDirective {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelOverride {
ReasoningEffort(ReasoningEffort),
ServiceTier(ServiceTier),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -79,19 +80,90 @@ impl ReasoningEffort {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServiceTier {
Priority,
}
impl ServiceTier {
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"fast" => Some(Self::Priority),
_ => None,
}
}
pub fn as_openai_value(self) -> &'static str {
match self {
Self::Priority => "priority",
}
}
}
pub fn parse_model_directive(model: &str) -> Option<ModelDirective> {
let model = model.trim();
let (base_model, suffix) = model.rsplit_once('-')?;
let base_model = base_model.trim();
let (base_model, overrides) = parse_model_directive_parts(model)?;
Some(ModelDirective {
base_model,
overrides,
})
}
fn parse_model_directive_parts(model: &str) -> Option<(String, Vec<ModelOverride>)> {
let mut base_model = model.trim();
let mut overrides = ModelOverrideAccumulator::default();
while let Some((candidate_base, suffix)) = base_model.rsplit_once('-') {
let Some(override_item) = parse_model_override(suffix) else {
break;
};
overrides.insert(override_item)?;
base_model = candidate_base.trim();
}
if base_model.is_empty() {
return None;
}
let overrides = overrides.into_overrides()?;
Some((base_model.to_string(), overrides))
}
let reasoning_effort = ReasoningEffort::parse(suffix)?;
Some(ModelDirective {
base_model: base_model.to_string(),
overrides: vec![ModelOverride::ReasoningEffort(reasoning_effort)],
})
fn parse_model_override(suffix: &str) -> Option<ModelOverride> {
ReasoningEffort::parse(suffix)
.map(ModelOverride::ReasoningEffort)
.or_else(|| ServiceTier::parse(suffix).map(ModelOverride::ServiceTier))
}
#[derive(Default)]
struct ModelOverrideAccumulator {
reasoning_effort: Option<ReasoningEffort>,
service_tier: Option<ServiceTier>,
}
impl ModelOverrideAccumulator {
fn insert(&mut self, override_item: ModelOverride) -> Option<()> {
match override_item {
ModelOverride::ReasoningEffort(value) => {
if self.reasoning_effort.replace(value).is_some() {
return None;
}
}
ModelOverride::ServiceTier(value) => {
if self.service_tier.replace(value).is_some() {
return None;
}
}
}
Some(())
}
fn into_overrides(self) -> Option<Vec<ModelOverride>> {
let mut overrides = Vec::new();
if let Some(reasoning_effort) = self.reasoning_effort {
overrides.push(ModelOverride::ReasoningEffort(reasoning_effort));
}
if let Some(service_tier) = self.service_tier {
overrides.push(ModelOverride::ServiceTier(service_tier));
}
(!overrides.is_empty()).then_some(overrides)
}
}
pub fn model_directive_base_model(model: &str) -> Option<String> {
@@ -149,18 +221,23 @@ pub fn apply_model_directive_overrides_from_model(
source_model: &str,
) -> Option<ModelDirective> {
let directive = parse_model_directive(source_model)?;
let mut patched_body = provider_request_body.clone();
for override_item in &directive.overrides {
match override_item {
ModelOverride::ReasoningEffort(effort) => {
apply_reasoning_effort_override(
provider_request_body,
&mut patched_body,
provider_api_format,
provider_model,
*effort,
)?;
}
ModelOverride::ServiceTier(tier) => {
apply_service_tier_override(&mut patched_body, provider_api_format, *tier)?;
}
}
}
*provider_request_body = patched_body;
Some(directive)
}
@@ -215,6 +292,21 @@ fn apply_reasoning_effort_override(
}
}
fn apply_service_tier_override(
provider_request_body: &mut Value,
provider_api_format: &str,
tier: ServiceTier,
) -> Option<()> {
match crate::normalize_api_format_alias(provider_api_format).as_str() {
"openai:chat" | "openai:responses" | "openai:responses:compact" => set_object_string(
provider_request_body,
"service_tier",
tier.as_openai_value(),
),
_ => None,
}
}
fn set_object_string(body: &mut Value, key: &str, value: &str) -> Option<()> {
body.as_object_mut()?
.insert(key.to_string(), Value::String(value.to_string()));
@@ -368,7 +460,7 @@ mod tests {
use super::{
apply_model_directive_overrides_from_model, parse_model_directive, ModelDirective,
ModelOverride, ReasoningEffort,
ModelOverride, ReasoningEffort, ServiceTier,
};
#[test]
@@ -389,12 +481,37 @@ mod tests {
);
}
#[test]
fn parses_supported_service_tier_suffixes() {
assert_eq!(
parse_model_directive("gpt-5.4-fast"),
Some(ModelDirective {
base_model: "gpt-5.4".to_string(),
overrides: vec![ModelOverride::ServiceTier(ServiceTier::Priority)],
})
);
}
#[test]
fn parses_combined_suffixes_in_canonical_order() {
let expected = Some(ModelDirective {
base_model: "gpt-5.4".to_string(),
overrides: vec![
ModelOverride::ReasoningEffort(ReasoningEffort::XHigh),
ModelOverride::ServiceTier(ServiceTier::Priority),
],
});
assert_eq!(parse_model_directive("gpt-5.4-fast-xhigh"), expected);
assert_eq!(parse_model_directive("gpt-5.4-xhigh-fast"), expected);
}
#[test]
fn ignores_unknown_or_incomplete_suffixes() {
assert_eq!(parse_model_directive("gpt-5.4-ultra"), None);
assert_eq!(parse_model_directive("gpt-5.4"), None);
assert_eq!(parse_model_directive("-high"), None);
assert_eq!(parse_model_directive("gpt-5.4-high-json"), None);
assert_eq!(parse_model_directive("gpt-5.4-low-high"), None);
}
#[test]
@@ -446,4 +563,65 @@ mod tests {
2048
);
}
#[test]
fn applies_fast_suffix_to_openai_service_tier() {
let mut openai_chat = json!({"model": "gpt-5-upstream"});
apply_model_directive_overrides_from_model(
&mut openai_chat,
"openai:chat",
"gpt-5-upstream",
"gpt-5.4-fast",
)
.expect("directive should apply");
assert_eq!(openai_chat["service_tier"], "priority");
let mut responses = json!({"model": "gpt-5-upstream"});
apply_model_directive_overrides_from_model(
&mut responses,
"openai:responses",
"gpt-5-upstream",
"gpt-5.4-fast",
)
.expect("directive should apply");
assert_eq!(responses["service_tier"], "priority");
}
#[test]
fn applies_combined_suffixes_to_openai_body() {
let mut openai_chat = json!({"model": "gpt-5-upstream", "reasoning_effort": "low"});
apply_model_directive_overrides_from_model(
&mut openai_chat,
"openai:chat",
"gpt-5-upstream",
"gpt-5.4-fast-xhigh",
)
.expect("directive should apply");
assert_eq!(openai_chat["reasoning_effort"], "xhigh");
assert_eq!(openai_chat["service_tier"], "priority");
let mut reversed = json!({"model": "gpt-5-upstream", "reasoning_effort": "low"});
apply_model_directive_overrides_from_model(
&mut reversed,
"openai:chat",
"gpt-5-upstream",
"gpt-5.4-xhigh-fast",
)
.expect("directive should apply");
assert_eq!(reversed, openai_chat);
}
#[test]
fn unsupported_combined_suffix_leaves_body_unchanged() {
let mut claude = json!({"model": "claude-sonnet-4-5"});
let original = claude.clone();
assert!(apply_model_directive_overrides_from_model(
&mut claude,
"claude:messages",
"claude-sonnet-4-5",
"gpt-5.4-fast-xhigh",
)
.is_none());
assert_eq!(claude, original);
}
}
@@ -29,7 +29,10 @@ pub fn canonicalize_tool_arguments(value: Option<Value>) -> String {
}
}
pub fn remove_empty_pages_from_tool_arguments(arguments: &str) -> String {
pub fn remove_empty_pages_from_tool_arguments(tool_name: &str, arguments: &str) -> String {
if tool_name != "Read" {
return arguments.to_string();
}
let Ok(mut value) = serde_json::from_str::<Value>(arguments) else {
return arguments.to_string();
};
@@ -43,6 +46,44 @@ pub fn remove_empty_pages_from_tool_arguments(arguments: &str) -> String {
serde_json::to_string(&value).unwrap_or_else(|_| arguments.to_string())
}
pub fn remove_empty_pages_from_tool_input_value(tool_name: &str, input: &Value) -> Value {
if tool_name != "Read" || input.get("pages").and_then(Value::as_str) != Some("") {
return input.clone();
}
let Some(object) = input.as_object() else {
return input.clone();
};
let mut object = object.clone();
object.remove("pages");
Value::Object(object)
}
pub fn sanitize_claude_read_tool_inputs(value: &mut Value) -> bool {
let Some(content) = value.get_mut("content").and_then(Value::as_array_mut) else {
return false;
};
let mut changed = false;
for block in content {
let Some(block_object) = block.as_object_mut() else {
continue;
};
if block_object.get("type").and_then(Value::as_str) != Some("tool_use")
|| block_object.get("name").and_then(Value::as_str) != Some("Read")
{
continue;
}
let Some(input) = block_object.get("input") else {
continue;
};
let sanitized = remove_empty_pages_from_tool_input_value("Read", input);
if sanitized != *input {
block_object.insert("input".to_string(), sanitized);
changed = true;
}
}
changed
}
pub fn prepare_local_success_response_parts(
headers: &BTreeMap<String, String>,
body_json: &Value,
@@ -131,7 +172,8 @@ mod tests {
build_generated_tool_call_id, build_local_success_background_report,
build_local_success_conversion_background_report, canonicalize_tool_arguments,
prepare_local_success_response_parts, prepare_local_success_response_parts_owned,
remove_empty_pages_from_tool_arguments, LocalSyncReportParts,
remove_empty_pages_from_tool_arguments, sanitize_claude_read_tool_inputs,
LocalSyncReportParts,
};
use std::collections::BTreeMap;
@@ -153,20 +195,77 @@ mod tests {
fn removes_empty_pages_from_tool_arguments() {
assert_eq!(
remove_empty_pages_from_tool_arguments(
"Read",
r#"{"file_path":"/tmp/a.txt","offset":1,"limit":20,"pages":""}"#
),
r#"{"file_path":"/tmp/a.txt","offset":1,"limit":20}"#
);
assert_eq!(
remove_empty_pages_from_tool_arguments(r#"{"pages":"1-2"}"#),
remove_empty_pages_from_tool_arguments("Search", r#"{"query":"","pages":""}"#),
r#"{"query":"","pages":""}"#
);
assert_eq!(
remove_empty_pages_from_tool_arguments("Read", r#"{"pages":"1-2"}"#),
r#"{"pages":"1-2"}"#
);
assert_eq!(
remove_empty_pages_from_tool_arguments(r#"{"pages":"#),
remove_empty_pages_from_tool_arguments("Read", r#"{"pages":"#),
r#"{"pages":"#
);
}
#[test]
fn sanitizes_claude_read_tool_inputs_only() {
let mut value = serde_json::json!({
"content": [
{
"type": "tool_use",
"name": "Read",
"input": {
"file_path": "/tmp/a.txt",
"limit": 20,
"pages": ""
}
},
{
"type": "tool_use",
"name": "Search",
"input": {
"query": "",
"pages": ""
}
},
{
"type": "tool_use",
"name": "Read",
"input": {
"pages": "1-2"
}
}
]
});
assert!(sanitize_claude_read_tool_inputs(&mut value));
assert_eq!(
value["content"][0]["input"],
serde_json::json!({
"file_path": "/tmp/a.txt",
"limit": 20,
})
);
assert_eq!(
value["content"][1]["input"],
serde_json::json!({
"query": "",
"pages": "",
})
);
assert_eq!(
value["content"][2]["input"],
serde_json::json!({"pages": "1-2"})
);
}
#[test]
fn prepare_local_success_response_parts_normalizes_headers() {
let headers = BTreeMap::from([
@@ -69,6 +69,14 @@ pub fn resolve_execution_runtime_stream_plan_kind(
));
}
if route_family == Some("antigravity")
&& route_kind == Some("stream_generate_content")
&& *method == Method::POST
&& path == "/v1internal:streamGenerateContent"
{
return Some(GEMINI_CLI_STREAM_PLAN_KIND);
}
if route_family == Some("openai")
&& is_openai_responses_route_kind(route_kind)
&& *method == Method::POST
@@ -679,6 +687,32 @@ mod tests {
);
}
#[test]
fn resolves_antigravity_v1internal_stream_plan_kind_as_gemini_cli_stream() {
assert_eq!(
resolve_execution_runtime_stream_plan_kind(
Some("ai_public"),
Some("antigravity"),
Some("stream_generate_content"),
Some("bearer_like"),
&Method::POST,
"/v1internal:streamGenerateContent",
),
Some(GEMINI_CLI_STREAM_PLAN_KIND)
);
assert_eq!(
resolve_execution_runtime_sync_plan_kind(
Some("ai_public"),
Some("antigravity"),
Some("stream_generate_content"),
Some("bearer_like"),
&Method::POST,
"/v1internal:streamGenerateContent",
),
None
);
}
#[test]
fn stream_path_detection_handles_gemini_method_paths_with_query() {
assert!(request_path_implies_stream_request(
@@ -551,6 +551,101 @@ mod tests {
assert_eq!(converted["stream"], true);
}
#[test]
fn standard_openai_chat_to_claude_normalizes_multiturn_tool_history() {
let request = json!({
"model": "deepseek-v4-flash",
"messages": [
{"role": "system", "content": "Be precise."},
{"role": "user", "content": "check two things"},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "weather-1",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\":\"NYC\"}"}
},
{
"id": "call_2",
"type": "function",
"function": {"name": "lookup", "arguments": "{\"q\":\"db\"}"}
}
]
},
{"role": "tool", "tool_call_id": "weather-1", "content": ""},
{"role": "tool", "tool_call_id": "call_2", "content": [{"type": "text", "text": "rows=1"}]},
{"role": "user", "content": "now answer"}
],
"tools": [
{
"type": "function",
"function": {"name": "get_weather", "description": "Get weather", "parameters": null}
},
{
"type": "function",
"function": {
"name": "lookup",
"description": "Lookup data",
"parameters": {"properties": {"q": {"type": "string"}}}
}
}
],
"parallel_tool_calls": true,
"max_tokens": 128,
"stream": true
});
let converted = build_standard_request_body(
&request,
"openai:chat",
"claude-sonnet-4-5",
"custom",
"claude:messages",
"/v1/chat/completions",
true,
None,
None,
)
.expect("openai chat tool history should build as claude messages");
assert_eq!(converted["model"], "claude-sonnet-4-5");
assert_eq!(converted["system"], "Be precise.");
assert_eq!(converted["messages"][0]["role"], "user");
assert_eq!(converted["messages"][1]["role"], "assistant");
assert_eq!(converted["messages"][1]["content"][0]["type"], "tool_use");
assert_eq!(
converted["messages"][1]["content"][0]["id"],
"toolu_weather-1"
);
assert_eq!(converted["messages"][1]["content"][1]["id"], "call_2");
assert_eq!(converted["messages"][2]["role"], "user");
assert_eq!(
converted["messages"][2]["content"][0]["type"],
"tool_result"
);
assert_eq!(
converted["messages"][2]["content"][0]["tool_use_id"],
"toolu_weather-1"
);
assert_eq!(converted["messages"][2]["content"][0]["content"], "(empty)");
assert_eq!(
converted["messages"][2]["content"][1]["tool_use_id"],
"call_2"
);
assert_eq!(converted["messages"][2]["content"][1]["content"], "rows=1");
assert_eq!(converted["messages"][2]["content"][2]["type"], "text");
assert_eq!(converted["messages"][2]["content"][2]["text"], "now answer");
assert_eq!(converted["tools"][0]["input_schema"]["type"], "object");
assert_eq!(
converted["tools"][0]["input_schema"]["properties"],
json!({})
);
assert_eq!(converted["tools"][1]["input_schema"]["type"], "object");
assert_eq!(converted["stream"], true);
}
fn codex_default_body_rules() -> Value {
json!([
{"action":"drop","path":"max_output_tokens"},
@@ -3,6 +3,8 @@ use std::borrow::Cow;
use aether_ai_formats::formats::conversion::request::{
convert_openai_chat_request_to_claude_request, convert_openai_chat_request_to_gemini_request,
convert_openai_chat_request_to_openai_responses_request,
normalize_claude_request_to_openai_chat_request,
normalize_gemini_request_to_openai_chat_request,
normalize_openai_responses_request_to_openai_chat_request,
};
use aether_ai_formats::{request_conversion_kind, FormatContext, RequestConversionKind};
@@ -62,6 +64,25 @@ fn chat_compatible_body_for_openai_chat_endpoint(body_json: &Value) -> Option<Co
Some(Cow::Borrowed(body_json))
}
fn chat_compatible_body_for_standard_source<'a>(
body_json: &'a Value,
client_api_format: &str,
) -> Option<Cow<'a, Value>> {
match aether_ai_formats::normalize_api_format_alias(client_api_format).as_str() {
"openai:chat" => chat_compatible_body_for_openai_chat_endpoint(body_json),
"openai:responses" | "openai:responses:compact" => {
normalize_openai_responses_request_to_openai_chat_request(body_json).map(Cow::Owned)
}
"claude:messages" => {
normalize_claude_request_to_openai_chat_request(body_json).map(Cow::Owned)
}
"gemini:generate_content" => {
normalize_gemini_request_to_openai_chat_request(body_json, "").map(Cow::Owned)
}
_ => None,
}
}
pub fn build_local_openai_chat_request_body(
body_json: &Value,
mapped_model: &str,
@@ -304,12 +325,12 @@ pub fn build_cross_format_openai_responses_request_body_with_model_directives(
upstream_is_stream: bool,
enable_model_directives: bool,
) -> Option<Value> {
let chat_like_request = normalize_openai_responses_request_to_openai_chat_request(body_json)?;
let chat_like_request = chat_compatible_body_for_standard_source(body_json, client_api_format)?;
let conversion_kind = request_conversion_kind(client_api_format, provider_api_format)?;
let provider_request_body = match conversion_kind {
RequestConversionKind::ToOpenAIChat => {
build_local_openai_chat_request_body_with_model_directives(
&chat_like_request,
chat_like_request.as_ref(),
mapped_model,
upstream_is_stream,
enable_model_directives,
@@ -317,19 +338,19 @@ pub fn build_cross_format_openai_responses_request_body_with_model_directives(
}
RequestConversionKind::ToOpenAiResponses => {
convert_openai_chat_request_to_openai_responses_request(
&chat_like_request,
chat_like_request.as_ref(),
mapped_model,
upstream_is_stream,
false,
)?
}
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
&chat_like_request,
chat_like_request.as_ref(),
mapped_model,
upstream_is_stream,
)?,
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
&chat_like_request,
chat_like_request.as_ref(),
mapped_model,
upstream_is_stream,
)?,
@@ -519,6 +540,39 @@ mod tests {
assert!(provider_request_body.get("messages").is_none());
}
#[test]
fn cross_format_openai_responses_body_preserves_chat_messages_for_chat_source() {
let body_json = json!({
"model": "gpt-5.5",
"messages": [
{"role": "system", "content": "Return a JSON object."},
{"role": "user", "content": "Explain why this JSON patch failed."}
],
"response_format": {"type": "json_object"}
});
let provider_request_body = build_cross_format_openai_responses_request_body(
&body_json,
"gpt-5.5-upstream",
"openai:chat",
"openai:responses",
false,
)
.expect("openai chat to openai responses body should build");
assert_eq!(provider_request_body["model"], "gpt-5.5-upstream");
assert_eq!(
provider_request_body["text"]["format"]["type"],
"json_object"
);
assert_eq!(provider_request_body["input"][0]["role"], "user");
assert_eq!(
provider_request_body["input"][0]["content"][0]["text"],
"Explain why this JSON patch failed."
);
assert!(provider_request_body.get("messages").is_none());
}
#[test]
fn openai_chat_request_body_prefers_messages_when_messages_and_input_are_both_present() {
let body_json = json!({
@@ -573,6 +573,83 @@ mod tests {
}
}
#[test]
fn transforms_openai_responses_text_snapshot_deltas_to_openai_chat_without_duplicates() {
let report_context = report_context("openai:responses", "openai:chat");
let mut matrix = StreamingStandardFormatMatrix::default();
let mut output = Vec::new();
for line in [
data_line(json!({
"type": "response.output_text.delta",
"response_id": "resp_snapshot_delta",
"output_index": 0,
"content_index": 0,
"delta": {
"text": "Hello",
}
})),
data_line(json!({
"type": "response.output_text.delta",
"response_id": "resp_snapshot_delta",
"output_index": 0,
"content_index": 0,
"delta": {
"text": "Hello world",
}
})),
data_line(json!({
"type": "response.output_text.done",
"response_id": "resp_snapshot_delta",
"output_index": 0,
"content_index": 0,
"text": "Hello world",
})),
data_line(json!({
"type": "response.completed",
"response": {
"id": "resp_snapshot_delta",
"object": "response",
"model": "gpt-5.4",
"status": "completed",
"output": [{
"type": "message",
"id": "msg_snapshot_delta",
"role": "assistant",
"status": "completed",
"content": [{
"type": "output_text",
"text": "Hello world",
"annotations": [],
}]
}],
}
})),
] {
output.extend(
matrix
.transform_line(&report_context, line)
.expect("responses stream line should convert"),
);
}
let sse = String::from_utf8(output).expect("sse should be utf8");
let content = sse
.lines()
.filter_map(|line| line.strip_prefix("data: "))
.filter_map(|payload| serde_json::from_str::<Value>(payload).ok())
.filter_map(|value| {
value
.pointer("/choices/0/delta/content")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
.collect::<String>();
assert_eq!(content, "Hello world");
assert!(!sse.contains("HelloHello"));
}
#[test]
fn transforms_provider_errors_to_claude_error_events() {
let cases = [
@@ -1,7 +1,13 @@
use serde_json::Value;
use std::collections::BTreeMap;
use serde_json::{json, Map, Value};
use crate::formats::openai::image::stream::{OpenAiImageChatStreamState, OpenAiImageStreamState};
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,
};
use crate::formats::shared::sse::encode_json_sse;
use crate::formats::shared::stream_core::StreamingStandardFormatMatrix;
use crate::formats::shared::AiSurfaceFinalizeError;
use crate::provider_compat::kiro_stream::KiroToClaudeCliStreamState;
@@ -16,6 +22,7 @@ pub enum FinalizeStreamRewriteMode {
ModelDirectiveDisplay,
OpenAiImage,
OpenAiImageToOpenAiChat,
ClaudeReadToolSanitize,
Standard,
KiroToClaudeCli,
KiroToClaudeCliThenStandard,
@@ -47,6 +54,18 @@ pub fn resolve_finalize_stream_rewrite_mode(
.trim()
.to_ascii_lowercase();
if !needs_conversion
&& client_consumes_same_private_stream_envelope(
report_context,
envelope_name.as_str(),
provider_api_format.as_str(),
client_api_format.as_str(),
)
{
return model_directive_display_model_from_report_context(report_context)
.map(|_| FinalizeStreamRewriteMode::ModelDirectiveDisplay);
}
if needs_conversion
&& envelope_name.eq_ignore_ascii_case(KIRO_ENVELOPE_NAME)
&& provider_api_format == "claude:messages"
@@ -72,6 +91,9 @@ pub fn resolve_finalize_stream_rewrite_mode(
// Parsing→rebuilding only adds overhead and may lose information
// (encrypted_content, original item IDs, etc.).
if is_same_format_family(provider_api_format.as_str(), client_api_format.as_str()) {
if provider_api_format == "claude:messages" && client_api_format == "claude:messages" {
return Some(FinalizeStreamRewriteMode::ClaudeReadToolSanitize);
}
return model_directive_display_model_from_report_context(report_context)
.map(|_| FinalizeStreamRewriteMode::ModelDirectiveDisplay);
}
@@ -96,9 +118,16 @@ pub fn resolve_finalize_stream_rewrite_mode(
provider_api_format.as_str(),
)
{
if provider_api_format == "claude:messages" {
return Some(FinalizeStreamRewriteMode::ClaudeReadToolSanitize);
}
return Some(FinalizeStreamRewriteMode::ModelDirectiveDisplay);
}
if provider_api_format == "claude:messages" && client_api_format == "claude:messages" {
return Some(FinalizeStreamRewriteMode::ClaudeReadToolSanitize);
}
(provider_api_format == client_api_format
&& provider_adaptation_should_unwrap_stream_envelope(
envelope_name.as_str(),
@@ -107,11 +136,32 @@ pub fn resolve_finalize_stream_rewrite_mode(
.then_some(FinalizeStreamRewriteMode::EnvelopeUnwrap)
}
fn client_consumes_same_private_stream_envelope(
report_context: &Value,
envelope_name: &str,
provider_api_format: &str,
client_api_format: &str,
) -> bool {
if envelope_name.is_empty()
|| provider_api_format != client_api_format
|| !provider_adaptation_should_unwrap_stream_envelope(envelope_name, provider_api_format)
{
return false;
}
report_context
.get("client_envelope_name")
.and_then(Value::as_str)
.is_some_and(|client_envelope_name| {
client_envelope_name.eq_ignore_ascii_case(envelope_name)
})
}
enum AiSurfaceStreamRewriteState {
EnvelopeUnwrap,
ModelDirectiveDisplay,
OpenAiImage(Box<OpenAiImageStreamState>),
OpenAiImageToOpenAiChat(Box<OpenAiImageChatStreamState>),
ClaudeReadToolSanitize(Box<ClaudeReadToolStreamSanitizer>),
Standard(Box<StreamingStandardFormatMatrix>),
KiroToClaudeCli(Box<KiroToClaudeCliStreamState>),
KiroToClaudeCliThenStandard {
@@ -143,6 +193,11 @@ pub fn maybe_build_ai_surface_stream_rewriter<'a>(
Box::<OpenAiImageChatStreamState>::default(),
)
}
FinalizeStreamRewriteMode::ClaudeReadToolSanitize => {
AiSurfaceStreamRewriteState::ClaudeReadToolSanitize(
Box::<ClaudeReadToolStreamSanitizer>::default(),
)
}
FinalizeStreamRewriteMode::Standard => {
AiSurfaceStreamRewriteState::Standard(Box::<StreamingStandardFormatMatrix>::default())
}
@@ -173,6 +228,9 @@ impl AiSurfaceStreamRewriter<'_> {
AiSurfaceStreamRewriteState::OpenAiImageToOpenAiChat(state) => {
state.push_chunk(self.report_context, chunk)
}
AiSurfaceStreamRewriteState::ClaudeReadToolSanitize(state) => {
state.push_chunk(self.report_context, chunk)
}
AiSurfaceStreamRewriteState::KiroToClaudeCli(state) => {
state.push_chunk(self.report_context, chunk)
}
@@ -200,6 +258,9 @@ impl AiSurfaceStreamRewriter<'_> {
AiSurfaceStreamRewriteState::OpenAiImageToOpenAiChat(state) => {
state.finish(self.report_context)
}
AiSurfaceStreamRewriteState::ClaudeReadToolSanitize(state) => {
state.finish(self.report_context)
}
AiSurfaceStreamRewriteState::KiroToClaudeCli(state) => {
state.finish(self.report_context)
}
@@ -246,12 +307,272 @@ impl AiSurfaceStreamRewriter<'_> {
}
AiSurfaceStreamRewriteState::OpenAiImage(_)
| AiSurfaceStreamRewriteState::OpenAiImageToOpenAiChat(_)
| AiSurfaceStreamRewriteState::ClaudeReadToolSanitize(_)
| AiSurfaceStreamRewriteState::KiroToClaudeCli(_)
| AiSurfaceStreamRewriteState::KiroToClaudeCliThenStandard { .. } => Ok(Vec::new()),
}
}
}
#[derive(Default)]
struct ClaudeReadToolBlockState {
name: String,
buffered_input_json: String,
}
#[derive(Default)]
struct ClaudeReadToolStreamSanitizer {
buffered: Vec<u8>,
blocks: BTreeMap<usize, ClaudeReadToolBlockState>,
}
impl ClaudeReadToolStreamSanitizer {
fn push_chunk(
&mut self,
report_context: &Value,
chunk: &[u8],
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
self.buffered.extend_from_slice(chunk);
let mut output = Vec::new();
while let Some(record) = drain_next_sse_record(&mut self.buffered) {
output.extend(self.transform_record(report_context, record)?);
}
Ok(output)
}
fn finish(&mut self, report_context: &Value) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.buffered.is_empty() {
return Ok(Vec::new());
}
let record = std::mem::take(&mut self.buffered);
self.transform_record(report_context, record)
}
fn transform_record(
&mut self,
report_context: &Value,
record: Vec<u8>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let Some((event, mut payload)) = parse_sse_record_json(&record) else {
return rewrite_model_directive_stream_record(report_context, record);
};
let event_type = payload
.get("type")
.and_then(Value::as_str)
.unwrap_or(event.as_deref().unwrap_or_default())
.to_string();
let mut output = match event_type.as_str() {
"content_block_start" => {
self.transform_content_block_start(event.as_deref(), payload, record)?
}
"content_block_delta" => self.transform_content_block_delta(payload, record)?,
"content_block_stop" => self.transform_content_block_stop(payload, record)?,
_ => {
if !rewrite_stream_payload_model_from_context(report_context, &mut payload) {
return Ok(record);
}
encode_json_sse(event.as_deref(), &payload)?
}
};
if model_directive_display_model_from_report_context(report_context).is_some()
&& !matches!(event_type.as_str(), "message_start" | "message_delta")
{
output = rewrite_model_directive_stream_record(report_context, output)?;
}
Ok(output)
}
fn transform_content_block_start(
&mut self,
event: Option<&str>,
mut payload: Value,
original_record: Vec<u8>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let index = payload
.get("index")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(0);
let Some(block) = payload
.get_mut("content_block")
.and_then(Value::as_object_mut)
else {
return Ok(original_record);
};
let block_type = block
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if block_type != "tool_use" {
return Ok(original_record);
}
let name = block
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
self.blocks.insert(
index,
ClaudeReadToolBlockState {
name: name.clone(),
buffered_input_json: String::new(),
},
);
if sanitize_claude_tool_input_object(block, &name) {
encode_json_sse(event, &payload)
} else {
Ok(original_record)
}
}
fn transform_content_block_delta(
&mut self,
payload: Value,
original_record: Vec<u8>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let index = payload
.get("index")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(0);
let delta_type = payload
.get("delta")
.and_then(Value::as_object)
.and_then(|delta| delta.get("type"))
.and_then(Value::as_str)
.unwrap_or_default();
let partial_json = payload
.get("delta")
.and_then(Value::as_object)
.and_then(|delta| delta.get("partial_json"))
.and_then(Value::as_str);
if delta_type != "input_json_delta" {
return Ok(original_record);
}
let Some(state) = self.blocks.get_mut(&index) else {
return Ok(original_record);
};
if state.name != "Read" {
return Ok(original_record);
}
if let Some(partial_json) = partial_json {
state.buffered_input_json.push_str(partial_json);
}
Ok(Vec::new())
}
fn transform_content_block_stop(
&mut self,
payload: Value,
original_record: Vec<u8>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let index = payload
.get("index")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(0);
let Some(state) = self.blocks.remove(&index) else {
return Ok(original_record);
};
let mut output = Vec::new();
if state.name == "Read" && !state.buffered_input_json.is_empty() {
let partial_json =
remove_empty_pages_from_tool_arguments("Read", &state.buffered_input_json);
if !partial_json.is_empty() {
output.extend(encode_json_sse(
Some("content_block_delta"),
&json!({
"type": "content_block_delta",
"index": index,
"delta": {
"type": "input_json_delta",
"partial_json": partial_json,
}
}),
)?);
}
}
if output.is_empty() {
output = original_record;
} else {
output.extend(original_record);
}
Ok(output)
}
}
fn sanitize_claude_tool_input_object(block: &mut Map<String, Value>, name: &str) -> bool {
let Some(input) = block.get("input") else {
return false;
};
let sanitized = remove_empty_pages_from_tool_input_value(name, input);
if sanitized == *input {
return false;
}
block.insert("input".to_string(), sanitized);
true
}
fn drain_next_sse_record(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
let mut line_start = 0usize;
let mut index = 0usize;
while index < buffer.len() {
if buffer[index] != b'\n' {
index += 1;
continue;
}
let line_end = index + 1;
let line = &buffer[line_start..line_end];
let line_without_newline = line
.strip_suffix(b"\n")
.unwrap_or(line)
.strip_suffix(b"\r")
.unwrap_or_else(|| line.strip_suffix(b"\n").unwrap_or(line));
if line_without_newline.is_empty() {
return Some(buffer.drain(..line_end).collect());
}
line_start = line_end;
index = line_end;
}
None
}
fn parse_sse_record_json(record: &[u8]) -> Option<(Option<String>, Value)> {
let text = std::str::from_utf8(record).ok()?;
let mut event = None;
let mut data = String::new();
for line in text.lines() {
let line = line.strip_suffix('\r').unwrap_or(line);
if let Some(value) = line.strip_prefix("event:") {
event = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("data:") {
if !data.is_empty() {
data.push('\n');
}
data.push_str(value.trim_start());
}
}
if data.trim().is_empty() || data.trim() == "[DONE]" {
return None;
}
let value = serde_json::from_str::<Value>(data.trim()).ok()?;
Some((event, value))
}
fn rewrite_model_directive_stream_record(
report_context: &Value,
record: Vec<u8>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut output = Vec::new();
for line in record.split_inclusive(|byte| *byte == b'\n') {
output.extend(rewrite_model_directive_stream_line(
report_context,
line.to_vec(),
)?);
}
Ok(output)
}
fn rewrite_model_directive_stream_line(
report_context: &Value,
line: Vec<u8>,
@@ -309,6 +630,14 @@ fn rewrite_stream_payload_model(value: &mut Value, display_model: &str) -> bool
changed
}
fn rewrite_stream_payload_model_from_context(report_context: &Value, value: &mut Value) -> bool {
let Some(display_model) = model_directive_display_model_from_report_context(report_context)
else {
return false;
};
rewrite_stream_payload_model(value, &display_model)
}
fn transform_standard_bytes(
standard: &mut StreamingStandardFormatMatrix,
report_context: &Value,
@@ -451,6 +780,45 @@ mod tests {
);
}
#[test]
fn resolves_no_rewriter_when_client_consumes_same_private_envelope() {
let report_context = json!({
"provider_api_format": "gemini:generate_content",
"client_api_format": "gemini:generate_content",
"envelope_name": "antigravity:v1internal",
"client_envelope_name": "antigravity:v1internal",
"needs_conversion": false,
});
assert_eq!(resolve_finalize_stream_rewrite_mode(&report_context), None);
assert!(maybe_build_ai_surface_stream_rewriter(Some(&report_context)).is_none());
}
#[test]
fn native_private_envelope_client_keeps_response_wrapper_for_model_display_rewrite() {
let report_context = json!({
"provider_api_format": "gemini:generate_content",
"client_api_format": "gemini:generate_content",
"envelope_name": "antigravity:v1internal",
"client_envelope_name": "antigravity:v1internal",
"model": "gemini-2.5-pro-high",
"mapped_model": "gemini-2.5-pro",
"needs_conversion": false,
});
let mut rewriter = maybe_build_ai_surface_stream_rewriter(Some(&report_context))
.expect("display-model rewriter should exist");
let output = rewriter
.push_chunk(
b"data: {\"response\":{\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[]},\"responseId\":\"resp_native_123\"}\n\n",
)
.expect("rewrite should succeed");
let output = String::from_utf8(output).expect("output should be utf8");
assert!(output.contains("\"response\":"));
assert!(output.contains("\"responseId\":\"resp_native_123\""));
assert!(output.contains("\"modelVersion\":\"gemini-2.5-pro-high\""));
assert!(!output.contains("_v1internal_response_id"));
}
#[test]
fn resolves_kiro_same_format_streams_to_kiro_mode() {
let report_context = json!({
@@ -647,14 +1015,106 @@ data: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"thinki
}
#[test]
fn same_format_claude_without_display_model_passes_through_verbatim() {
// Claude→Claude without display model: no rewriter needed at all.
fn same_format_claude_uses_read_tool_sanitizer_without_display_model() {
// Claude→Claude needs a narrow sanitizer for Claude Code Read input.
let report_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "claude:messages",
"needs_conversion": true,
});
assert!(maybe_build_ai_surface_stream_rewriter(Some(&report_context)).is_none());
assert_eq!(
resolve_finalize_stream_rewrite_mode(&report_context),
Some(FinalizeStreamRewriteMode::ClaudeReadToolSanitize)
);
}
#[test]
fn same_format_claude_stream_sanitizes_read_start_input() {
let report_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "claude:messages",
"needs_conversion": false,
});
let mut rewriter = maybe_build_ai_surface_stream_rewriter(Some(&report_context))
.expect("same-format claude sanitizer should exist");
let output = rewriter
.push_chunk(
b"event: content_block_start\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_read_1\",\"name\":\"Read\",\"input\":{\"file_path\":\"/tmp/a.txt\",\"limit\":20,\"pages\":\"\"}}}\n\n",
)
.expect("rewrite should succeed");
let output = String::from_utf8(output).expect("output should be utf8");
assert!(output.contains("\"name\":\"Read\""));
assert!(output.contains("\"file_path\":\"/tmp/a.txt\""));
assert!(!output.contains("\"pages\":\"\""));
}
#[test]
fn same_format_claude_stream_sanitizes_read_input_json_delta() {
let report_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "claude:messages",
"needs_conversion": false,
});
let mut rewriter = maybe_build_ai_surface_stream_rewriter(Some(&report_context))
.expect("same-format claude sanitizer should exist");
let mut output = rewriter
.push_chunk(
b"event: content_block_start\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_read_1\",\"name\":\"Read\",\"input\":{}}}\n\n",
)
.expect("start should rewrite");
output.extend(
rewriter
.push_chunk(
b"event: content_block_delta\n\
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"file_path\\\":\\\"/tmp/a.txt\\\",\"}}\n\n\
event: content_block_delta\n\
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"limit\\\":20,\\\"pages\\\":\\\"\\\"}\"}}\n\n",
)
.expect("deltas should buffer"),
);
let buffered_output = String::from_utf8(output.clone()).expect("output should be utf8");
assert!(!buffered_output.contains("input_json_delta"));
output.extend(
rewriter
.push_chunk(
b"event: content_block_stop\n\
data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
)
.expect("stop should flush sanitized delta"),
);
let output = String::from_utf8(output).expect("output should be utf8");
assert!(output.contains("event: content_block_delta"));
assert!(output.contains("\\\"limit\\\":20"));
assert!(!output.contains("\\\"pages\\\":\\\"\\\""));
assert!(output.contains("event: content_block_stop"));
}
#[test]
fn same_format_claude_stream_preserves_other_tool_empty_pages() {
let report_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "claude:messages",
"needs_conversion": false,
});
let mut rewriter = maybe_build_ai_surface_stream_rewriter(Some(&report_context))
.expect("same-format claude sanitizer should exist");
let output = rewriter
.push_chunk(
b"event: content_block_start\n\
data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_search_1\",\"name\":\"Search\",\"input\":{}}}\n\n\
event: content_block_delta\n\
data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"query\\\":\\\"\\\",\\\"pages\\\":\\\"\\\"}\"}}\n\n",
)
.expect("rewrite should succeed");
let output = String::from_utf8(output).expect("output should be utf8");
assert!(output.contains("\"name\":\"Search\""));
assert!(output.contains("\\\"pages\\\":\\\"\\\""));
}
#[test]
@@ -21,7 +21,10 @@ use serde_json::{json, Map, Value};
use super::AiSurfaceFinalizeError;
use crate::formats::gemini::generate_content::stream::GeminiProviderState;
use crate::formats::shared::model_directives::model_directive_display_model_from_report_context;
use crate::formats::shared::response::remove_empty_pages_from_tool_arguments;
use crate::formats::shared::response::{
remove_empty_pages_from_tool_arguments, remove_empty_pages_from_tool_input_value,
sanitize_claude_read_tool_inputs,
};
use crate::formats::shared::stream_core::common::{
content_part_from_openai_image_generation_item, map_openai_finish_reason_to_gemini,
parse_json_arguments_value, CanonicalContentPart, CanonicalStreamEvent, CanonicalUsage,
@@ -480,8 +483,13 @@ fn maybe_build_standard_same_format_sync_body(
return None;
}
let mut body_json = body_json.clone();
if expected_api_format == "claude:messages" {
sanitize_claude_read_tool_inputs(&mut body_json);
}
Some(client_body_with_report_context_model(
body_json.clone(),
body_json,
report_context,
&client_api_format,
))
@@ -1712,22 +1720,25 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
"response.output_text.delta" | "response.outtext.delta" => {
let output_index = openai_responses_event_output_index(event_object).unwrap_or(0);
let content_index = openai_responses_event_content_index(event_object);
let delta = match event_object.get("delta") {
Some(Value::String(text)) => text.as_str(),
Some(Value::Object(delta)) => delta
.get("text")
.and_then(Value::as_str)
.unwrap_or_default(),
_ => "",
};
if delta.is_empty() {
continue;
match event_object.get("delta") {
Some(Value::String(delta)) => {
append_openai_responses_message_text_delta(
message_states.entry(output_index).or_default(),
content_index,
delta,
);
}
Some(Value::Object(delta)) => {
if let Some(text) = delta.get("text").and_then(Value::as_str) {
merge_openai_responses_message_text_delta_object(
message_states.entry(output_index).or_default(),
content_index,
text,
);
}
}
_ => {}
}
append_openai_responses_message_text_delta(
message_states.entry(output_index).or_default(),
content_index,
delta,
);
}
"response.output_text.done" => {
let output_index = openai_responses_event_output_index(event_object).unwrap_or(0);
@@ -2101,6 +2112,46 @@ fn append_openai_responses_message_text_delta(
.or_insert_with(|| Value::Array(Vec::new()));
}
fn merge_openai_responses_message_text_delta_object(
state: &mut OpenAIResponsesSyncMessageState,
content_index: usize,
text: &str,
) {
if text.is_empty() {
return;
}
let part = state
.parts
.entry(content_index)
.or_insert_with(default_openai_responses_output_text_part);
let Some(part) = part.as_object_mut() else {
return;
};
if !part
.get("type")
.and_then(Value::as_str)
.is_some_and(|value| matches!(value, "output_text" | "text"))
{
return;
}
let current = part
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let merged = if text.starts_with(current.as_str()) {
text.to_string()
} else if current == text || current.starts_with(text) {
current
} else {
format!("{current}{text}")
};
part.insert("type".to_string(), Value::String("output_text".to_string()));
part.insert("text".to_string(), Value::String(merged));
part.entry("annotations".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
}
fn merge_openai_responses_message_text_part(
state: &mut OpenAIResponsesSyncMessageState,
content_index: usize,
@@ -2530,8 +2581,20 @@ pub fn aggregate_claude_stream_sync_response(body: &[u8]) -> Option<Value> {
}
}
"tool_use" => {
let tool_name = block
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if let Some(input) = block.get("input") {
let sanitized = remove_empty_pages_from_tool_input_value(&tool_name, input);
if sanitized != *input {
block.insert("input".to_string(), sanitized);
}
}
if !state.partial_json.is_empty() {
let arguments = remove_empty_pages_from_tool_arguments(&state.partial_json);
let arguments =
remove_empty_pages_from_tool_arguments(&tool_name, &state.partial_json);
let input = serde_json::from_str::<Value>(&arguments)
.unwrap_or(Value::String(arguments));
block.insert("input".to_string(), input);
@@ -3114,6 +3177,59 @@ mod tests {
);
}
#[test]
fn aggregates_claude_stream_removes_empty_pages_from_start_tool_input() {
let body = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-5\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_read\",\"name\":\"Read\",\"input\":{\"file_path\":\"/tmp/a.txt\",\"limit\":20,\"pages\":\"\"}}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n",
);
let aggregated =
aggregate_claude_stream_sync_response(body.as_bytes()).expect("body should aggregate");
assert_eq!(
aggregated["content"][0]["input"],
json!({
"file_path": "/tmp/a.txt",
"limit": 20,
})
);
}
#[test]
fn aggregates_claude_stream_preserves_empty_pages_for_non_read_tool_input() {
let body = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_123\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4-5\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_search\",\"name\":\"Search\",\"input\":{}}}\n\n",
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"query\\\":\\\"\\\",\\\"pages\\\":\\\"\\\"}\"}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n",
);
let aggregated =
aggregate_claude_stream_sync_response(body.as_bytes()).expect("body should aggregate");
assert_eq!(aggregated["content"][0]["type"], "tool_use");
assert_eq!(
aggregated["content"][0]["input"],
json!({
"query": "",
"pages": "",
})
);
}
#[test]
fn aggregates_gemini_stream_deltas_media_and_signatures_into_sync_body() {
let body = concat!(
@@ -3385,6 +3501,67 @@ mod tests {
assert_eq!(body_json, provider_body_json);
}
#[test]
fn same_format_claude_sync_body_sanitizes_read_tool_input() {
let report_context = json!({
"provider_api_format": "claude:messages",
"client_api_format": "claude:messages",
"needs_conversion": false,
});
let provider_body_json = json!({
"id": "msg_read",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-6",
"content": [
{
"type": "tool_use",
"id": "toolu_read",
"name": "Read",
"input": {
"file_path": "/tmp/a.txt",
"limit": 20,
"pages": ""
}
},
{
"type": "tool_use",
"id": "toolu_search",
"name": "Search",
"input": {
"query": "",
"pages": ""
}
}
]
});
let body_json = maybe_build_standard_same_format_sync_body_from_normalized_payload(
"claude_chat_sync_finalize",
200,
Some(&report_context),
Some(&provider_body_json),
None,
)
.expect("same-format sync body should succeed")
.expect("body should exist");
assert_eq!(
body_json["content"][0]["input"],
json!({
"file_path": "/tmp/a.txt",
"limit": 20,
})
);
assert_eq!(
body_json["content"][1]["input"],
json!({
"query": "",
"pages": "",
})
);
}
#[test]
fn same_format_sync_response_restores_model_directive_display_model() {
let report_context = json!({
@@ -3621,6 +3798,25 @@ mod tests {
assert_eq!(result["output"][0]["content"][1]["text"], " world");
}
#[test]
fn aggregates_openai_responses_text_snapshot_deltas_without_duplicates() {
let body = concat!(
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":{\"text\":\"Hello\"}}\n\n",
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":{\"text\":\"Hello world\"}}\n\n",
"event: response.output_text.done\n",
"data: {\"type\":\"response.output_text.done\",\"output_index\":0,\"content_index\":0,\"text\":\"Hello world\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_snapshot_123\",\"object\":\"response\",\"model\":\"gpt-5\",\"status\":\"completed\",\"output\":[]}}\n\n",
);
let result = aggregate_openai_responses_stream_sync_response(body.as_bytes())
.expect("openai-responses stream should aggregate into a sync body");
assert_eq!(result["output"][0]["content"][0]["text"], "Hello world");
}
#[test]
fn preserves_openai_responses_non_text_content_parts() {
let body = concat!(
@@ -20,6 +20,7 @@ use crate::formats::shared::stream_core::common::{
use crate::formats::shared::stream_core::{
CanonicalStreamFrame, StreamingStandardFormatMatrix, StreamingStandardTerminalObserver,
};
use crate::formats::shared::stream_rewrite::maybe_build_ai_surface_stream_rewriter;
use crate::formats::shared::AiSurfaceFinalizeError;
pub struct SyncToStreamBridgeOutcome {
@@ -668,7 +669,11 @@ fn maybe_bridge_aether_sse_response_capture_to_stream(
client_api_format,
);
let sse_body = if captured_api_format == client_api_format {
body_text.as_bytes().to_vec()
if captured_api_format == "claude:messages" {
sanitize_same_format_claude_sse_body(body_text.as_bytes(), report_context)?
} else {
body_text.as_bytes().to_vec()
}
} else {
rewrite_sse_body_between_formats(
body_text.as_bytes(),
@@ -689,6 +694,34 @@ fn maybe_bridge_aether_sse_response_capture_to_stream(
}))
}
fn sanitize_same_format_claude_sse_body(
body: &[u8],
report_context: Option<&Value>,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut context = report_context
.cloned()
.filter(Value::is_object)
.unwrap_or_else(|| json!({}));
let object = context
.as_object_mut()
.expect("same-format Claude context should stay object");
object.insert(
"provider_api_format".to_string(),
Value::String("claude:messages".to_string()),
);
object.insert(
"client_api_format".to_string(),
Value::String("claude:messages".to_string()),
);
let Some(mut rewriter) = maybe_build_ai_surface_stream_rewriter(Some(&context)) else {
return Ok(body.to_vec());
};
let mut out = rewriter.push_chunk(body)?;
out.extend(rewriter.finish()?);
Ok(out)
}
fn response_capture_header<'a>(headers: &'a Map<String, Value>, name: &str) -> Option<&'a str> {
headers
.iter()
@@ -1349,6 +1382,49 @@ mod tests {
);
}
#[test]
fn rewrites_same_format_claude_capture_to_sanitize_read_tool_input() {
let captured_body = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_read_1\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"gpt-5.5\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_read_1\",\"name\":\"Read\",\"input\":{\"file_path\":\"D:/projects/UIAutoTest/docs/prd/msr.md\",\"offset\":0,\"limit\":2000,\"pages\":\"\"}}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"server_tool_use\",\"id\":\"srv_1\",\"name\":\"web_search\",\"input\":{\"query\":\"rust\"}}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":1}\n\n",
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n",
);
let outcome = maybe_bridge_standard_sync_json_to_stream(
&json!({
"status_code": 200,
"headers": {
"content-type": "text/event-stream",
"x-aether-control-endpoint-signature": "claude:messages"
},
"body": captured_body
}),
"openai:responses",
"claude:messages",
None,
)
.expect("bridge should succeed")
.expect("capture should bridge");
let output = utf8(outcome.sse_body);
assert!(output.contains("\"name\":\"Read\""));
assert!(output.contains("\"limit\":2000"));
assert!(output.contains("\"type\":\"server_tool_use\""));
assert!(output.contains("\"name\":\"web_search\""));
assert!(!output.contains("\"pages\":\"\""));
assert!(!output.contains("\\\"pages\\\":\\\"\\\""));
}
#[test]
fn rewrites_aether_sse_response_capture_to_requested_client_stream() {
let captured_body = concat!(
+1 -1
View File
@@ -25,7 +25,7 @@ pub use formats::shared::model_directives::{
apply_model_directive_overrides_from_request, claude_model_uses_adaptive_effort,
extract_gemini_model_from_path, gemini_model_uses_thinking_level, model_directive_base_model,
normalize_model_directive_model, parse_model_directive, ModelDirective, ModelOverride,
ReasoningEffort,
ReasoningEffort, ServiceTier,
};
pub use formats::shared::request::{
endpoint_config_forces_upstream_stream_policy, enforce_request_body_stream_field,
@@ -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::response::remove_empty_pages_from_tool_input_value;
pub use crate::protocol::stream::{CanonicalStreamEvent, CanonicalStreamFrame};
@@ -11,6 +12,7 @@ pub(crate) const OPENAI_RESPONSES_EXTENSION_NAMESPACE: &str = "openai_responses"
pub(crate) const OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE: &str = "openai_cli";
const AETHER_EXTENSION_NAMESPACE: &str = "aether";
const CLAUDE_TOOL_RESULT_SOURCE_MARKER: &str = "claude_tool_result";
const OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER: &str = "openai_chat_tool_result";
const OPENAI_CHAT_TOOL_ERROR_PREFIX: &str = "[tool error]";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -725,7 +727,7 @@ pub(crate) fn openai_role_to_canonical(role: &str) -> CanonicalRole {
"assistant" => CanonicalRole::Assistant,
"system" => CanonicalRole::System,
"developer" => CanonicalRole::Developer,
"tool" => CanonicalRole::Tool,
"tool" | "function" => CanonicalRole::Tool,
_ => CanonicalRole::Unknown,
}
}
@@ -1329,10 +1331,12 @@ pub(crate) fn openai_message_content_blocks(
blocks.splice(0..0, reasoning_blocks);
}
}
let mut saw_tool_calls = false;
if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
for tool_call in tool_calls {
let tool_call = tool_call.as_object()?;
let function = tool_call.get("function").and_then(Value::as_object)?;
saw_tool_calls = true;
blocks.push(CanonicalContentBlock::ToolUse {
id: tool_call
.get("id")
@@ -1349,12 +1353,38 @@ pub(crate) fn openai_message_content_blocks(
});
}
}
if role == CanonicalRole::Assistant && !saw_tool_calls {
if let Some(function_call) = message.get("function_call").and_then(Value::as_object) {
let name = function_call
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
blocks.push(CanonicalContentBlock::ToolUse {
id: message
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or(name.as_str())
.to_string(),
name,
input: parse_jsonish_value(function_call.get("arguments")),
extensions: openai_extensions(message, &["role", "content", "function_call"]),
});
}
}
if role == CanonicalRole::Tool {
let text = openai_content_text(message.get("content"));
let mut extensions = openai_extensions(message, &["role", "content", "tool_call_id"]);
extensions.insert(
AETHER_EXTENSION_NAMESPACE.to_string(),
json!({ "source": OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER }),
);
blocks.push(CanonicalContentBlock::ToolResult {
tool_use_id: message
.get("tool_call_id")
.and_then(Value::as_str)
.or_else(|| message.get("name").and_then(Value::as_str))
.unwrap_or_default()
.to_string(),
name: None,
@@ -1374,7 +1404,7 @@ pub(crate) fn openai_message_content_blocks(
text
}),
is_error: false,
extensions: openai_extensions(message, &["role", "content", "tool_call_id"]),
extensions,
});
}
Some(blocks)
@@ -3187,28 +3217,136 @@ pub(crate) type GeminiCanonicalTools = (
Vec<Value>,
Option<Value>,
Option<Value>,
Option<Value>,
);
#[derive(Debug, Clone)]
pub(crate) struct GeminiGoogleSearchGrounding {
pub source_field: &'static str,
pub source_dialect: &'static str,
pub legacy: bool,
pub payload: Value,
pub raw_payload: Value,
pub output_payload: Value,
}
pub(crate) fn gemini_google_search_grounding(
tool_object: &Map<String, Value>,
) -> Option<GeminiGoogleSearchGrounding> {
for (field, source_dialect, legacy) in [
("googleSearch", "gemini_current", false),
("google_search", "gemini_current", false),
("googleSearchRetrieval", "vertex_legacy", true),
("google_search_retrieval", "vertex_legacy", true),
] {
if let Some(raw_payload) = tool_object.get(field) {
let raw_payload = normalize_gemini_tool_payload(raw_payload);
let payload = lower_camelize_json_object_keys(&raw_payload);
let output_payload = if legacy { json!({}) } else { payload.clone() };
return Some(GeminiGoogleSearchGrounding {
source_field: field,
source_dialect,
legacy,
payload,
raw_payload,
output_payload,
});
}
}
None
}
pub(crate) fn gemini_google_search_grounding_extension(
grounding: &GeminiGoogleSearchGrounding,
) -> Value {
json!({
"enabled": true,
"source_field": grounding.source_field,
"source_dialect": grounding.source_dialect,
"payload": grounding.payload,
"raw_payload": grounding.raw_payload,
"legacy": grounding.legacy,
})
}
fn normalize_gemini_tool_payload(payload: &Value) -> Value {
match payload {
Value::Null => json!({}),
value => value.clone(),
}
}
fn lower_camelize_json_object_keys(value: &Value) -> Value {
match value {
Value::Object(object) => Value::Object(
object
.iter()
.map(|(key, value)| {
(
snake_to_lower_camel(key),
lower_camelize_json_object_keys(value),
)
})
.collect(),
),
Value::Array(items) => {
Value::Array(items.iter().map(lower_camelize_json_object_keys).collect())
}
other => other.clone(),
}
}
fn snake_to_lower_camel(key: &str) -> String {
let mut output = String::with_capacity(key.len());
let mut uppercase_next = false;
for character in key.chars() {
if character == '_' {
uppercase_next = true;
continue;
}
if uppercase_next {
for uppercase in character.to_uppercase() {
output.push(uppercase);
}
uppercase_next = false;
} else {
output.push(character);
}
}
output
}
fn gemini_builtin_tool_portion(tool_object: &Map<String, Value>) -> Option<Value> {
let builtin = tool_object
.iter()
.filter(|(key, _)| {
key.as_str() != "functionDeclarations" && key.as_str() != "function_declarations"
})
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<Map<_, _>>();
(!builtin.is_empty()).then_some(Value::Object(builtin))
}
pub(crate) fn gemini_tools_to_canonical(value: Option<&Value>) -> Option<GeminiCanonicalTools> {
let Some(value) = value else {
return Some((Vec::new(), Vec::new(), None, None));
return Some((Vec::new(), Vec::new(), None, None, None));
};
let tools = value.as_array()?;
let mut canonical = Vec::new();
let mut builtin_tools = Vec::new();
let mut web_search_options = None;
let mut google_search_grounding = None;
for tool in tools {
let tool_object = tool.as_object()?;
if tool_object.get("googleSearch").is_some() || tool_object.get("google_search").is_some() {
if let Some(grounding) = gemini_google_search_grounding(tool_object) {
web_search_options = Some(json!({}));
builtin_tools.push(tool.clone());
if google_search_grounding.is_none() {
google_search_grounding =
Some(gemini_google_search_grounding_extension(&grounding));
}
}
if tool_object.get("codeExecution").is_some()
|| tool_object.get("code_execution").is_some()
|| tool_object.get("urlContext").is_some()
|| tool_object.get("url_context").is_some()
{
builtin_tools.push(tool.clone());
if let Some(builtin_tool) = gemini_builtin_tool_portion(tool_object) {
builtin_tools.push(builtin_tool);
}
let declarations = tool_object
.get("functionDeclarations")
@@ -3243,6 +3381,7 @@ pub(crate) fn gemini_tools_to_canonical(value: Option<&Value>) -> Option<GeminiC
builtin_tools,
web_search_options,
Some(value.clone()),
google_search_grounding,
))
}
@@ -3796,11 +3935,15 @@ pub(crate) fn canonical_block_to_claude(
input,
extensions,
} => {
let input = remove_empty_pages_from_tool_input_value(name, input);
let mut out = Map::new();
out.insert("type".to_string(), Value::String("tool_use".to_string()));
out.insert("id".to_string(), Value::String(id.clone()));
out.insert(
"id".to_string(),
Value::String(claude_compatible_tool_use_id(id)),
);
out.insert("name".to_string(), Value::String(name.clone()));
out.insert("input".to_string(), input.clone());
out.insert("input".to_string(), input);
out.extend(namespace_extension_object(extensions, "claude", &out));
Some(Some(Value::Object(out)))
}
@@ -3816,7 +3959,7 @@ pub(crate) fn canonical_block_to_claude(
out.insert("type".to_string(), Value::String("tool_result".to_string()));
out.insert(
"tool_use_id".to_string(),
Value::String(tool_use_id.clone()),
Value::String(claude_compatible_tool_use_id(tool_use_id)),
);
out.insert(
"content".to_string(),
@@ -3824,6 +3967,7 @@ pub(crate) fn canonical_block_to_claude(
output.as_ref(),
content_text.as_deref(),
role,
extensions,
),
);
out.insert("is_error".to_string(), Value::Bool(*is_error));
@@ -3838,6 +3982,7 @@ fn canonical_tool_result_content_to_claude(
output: Option<&Value>,
content_text: Option<&str>,
role: &CanonicalRole,
extensions: &BTreeMap<String, Value>,
) -> Value {
if matches!(role, CanonicalRole::Assistant) {
return output
@@ -3845,15 +3990,54 @@ fn canonical_tool_result_content_to_claude(
.unwrap_or_else(|| Value::String(content_text.unwrap_or_default().to_string()));
}
if is_openai_chat_tool_result(extensions) {
let text = content_text
.map(ToOwned::to_owned)
.or_else(|| output.map(openai_responses_tool_output_text))
.unwrap_or_default();
return Value::String(non_empty_tool_result_text(&text));
}
match output {
Some(Value::String(text)) => Value::String(text.clone()),
Some(Value::String(text)) => Value::String(non_empty_tool_result_text(text)),
Some(Value::Array(parts)) if claude_tool_result_content_blocks_are_wire_safe(parts) => {
Value::Array(parts.clone())
}
Some(Value::Null) => Value::String(non_empty_tool_result_text("")),
Some(value) => serde_json::to_string(value)
.map(Value::String)
.unwrap_or_else(|_| Value::String(content_text.unwrap_or_default().to_string())),
None => Value::String(content_text.unwrap_or_default().to_string()),
.map(|text| Value::String(non_empty_tool_result_text(&text)))
.unwrap_or_else(|_| {
Value::String(non_empty_tool_result_text(content_text.unwrap_or_default()))
}),
None => Value::String(non_empty_tool_result_text(content_text.unwrap_or_default())),
}
}
fn is_openai_chat_tool_result(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)
.and_then(|value| value.get("source"))
.and_then(Value::as_str)
== Some(OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER)
}
fn non_empty_tool_result_text(text: &str) -> String {
if text.trim().is_empty() {
"(empty)".to_string()
} else {
text.to_string()
}
}
fn claude_compatible_tool_use_id(id: &str) -> String {
let trimmed = id.trim();
if trimmed.is_empty() {
return "toolu_".to_string();
}
if trimmed.starts_with("toolu_") || trimmed.starts_with("call_") {
trimmed.to_string()
} else {
format!("toolu_{trimmed}")
}
}
@@ -3986,7 +4170,7 @@ pub(crate) fn canonical_tools_to_claude(canonical: &CanonicalRequest) -> Vec<Val
}
out.insert(
"input_schema".to_string(),
tool.parameters.clone().unwrap_or_else(|| json!({})),
claude_input_schema_from_tool_parameters(tool.parameters.as_ref()),
);
out.extend(namespace_extension_object(&tool.extensions, "claude", &out));
Value::Object(out)
@@ -4004,6 +4188,23 @@ pub(crate) fn canonical_tools_to_claude(canonical: &CanonicalRequest) -> Vec<Val
tools
}
fn claude_input_schema_from_tool_parameters(parameters: Option<&Value>) -> Value {
match parameters {
Some(Value::Object(schema)) => {
let mut schema = schema.clone();
schema
.entry("type".to_string())
.or_insert_with(|| Value::String("object".to_string()));
schema
.entry("properties".to_string())
.or_insert_with(|| json!({}));
Value::Object(schema)
}
Some(Value::Null) | None => json!({"type": "object", "properties": {}}),
Some(_) => json!({"type": "object", "properties": {}}),
}
}
pub(crate) fn canonical_tool_choice_to_claude(
choice: Option<&CanonicalToolChoice>,
parallel_tool_calls: Option<bool>,
@@ -4074,7 +4275,13 @@ pub(crate) fn apply_gemini_request_extensions(
output_object.insert("cachedContent".to_string(), cached_content);
}
if let Some(raw_tools) = gemini.get("raw_tools").cloned() {
output_object.insert("tools".to_string(), raw_tools);
if should_reuse_raw_gemini_tools(gemini) {
output_object.insert("tools".to_string(), raw_tools);
} else {
output_object
.entry("tools".to_string())
.or_insert(raw_tools);
}
}
if let Some(raw_tool_config) = gemini.get("raw_tool_config").cloned() {
output_object.insert("toolConfig".to_string(), raw_tool_config);
@@ -4082,6 +4289,25 @@ pub(crate) fn apply_gemini_request_extensions(
Some(())
}
fn should_reuse_raw_gemini_tools(gemini: &Map<String, Value>) -> bool {
let Some(google_search) = gemini
.get("grounding")
.and_then(Value::as_object)
.and_then(|grounding| grounding.get("google_search"))
.and_then(Value::as_object)
else {
return true;
};
google_search
.get("legacy")
.and_then(Value::as_bool)
.is_none_or(|legacy| !legacy)
&& google_search
.get("source_field")
.and_then(Value::as_str)
.is_some_and(|source_field| source_field == "googleSearch")
}
pub(crate) fn assistant_image_placeholder(url: Option<&str>, has_data: bool) -> String {
match (url, has_data) {
(Some(url), false) if !url.trim().is_empty() => format!("[Image: {url}]"),
@@ -5506,6 +5732,74 @@ mod tests {
assert_eq!(rebuilt["service_tier"], "flex");
}
#[test]
fn openai_responses_to_claude_response_drops_empty_pages_only_for_read_tool() {
let response = json!({
"id": "resp_read_pages",
"object": "response",
"status": "completed",
"model": "gpt-5.5",
"output": [
{
"type": "function_call",
"id": "call_read",
"call_id": "call_read",
"name": "Read",
"arguments": "{\"file_path\":\"/tmp/a.txt\",\"offset\":0,\"limit\":20,\"pages\":\"\"}"
},
{
"type": "function_call",
"id": "call_search",
"call_id": "call_search",
"name": "Search",
"arguments": "{\"query\":\"\",\"pages\":\"\"}"
}
],
"usage": {
"input_tokens": 1,
"output_tokens": 1,
"total_tokens": 2
}
});
let canonical =
from_openai_responses_to_canonical_response(&response).expect("canonical response");
let claude = canonical_to_claude_response(&canonical);
assert_eq!(
claude["content"][0]["input"],
json!({
"file_path": "/tmp/a.txt",
"offset": 0,
"limit": 20,
})
);
assert_eq!(
claude["content"][1]["input"],
json!({
"query": "",
"pages": "",
})
);
let rebuilt_responses = canonical_to_openai_responses_response(&canonical, &json!({}));
let read_arguments = serde_json::from_str::<Value>(
rebuilt_responses["output"][0]["arguments"]
.as_str()
.expect("arguments should be a string"),
)
.expect("arguments should be json");
assert_eq!(
read_arguments,
json!({
"file_path": "/tmp/a.txt",
"offset": 0,
"limit": 20,
"pages": "",
})
);
}
#[test]
fn openai_responses_image_generation_call_becomes_canonical_image_block() {
let response = json!({
@@ -5840,6 +6134,235 @@ mod tests {
assert_eq!(rebuilt["toolConfig"], request["toolConfig"]);
}
#[test]
fn gemini_request_adapter_normalizes_google_search_grounding_aliases() {
let cases = [
(
"current_camel",
json!({"googleSearch": {"excludeDomains": ["example.com"]}}),
"googleSearch",
false,
json!({"excludeDomains": ["example.com"]}),
json!({"excludeDomains": ["example.com"]}),
),
(
"current_snake",
json!({"google_search": {"exclude_domains": ["example.com"]}}),
"google_search",
false,
json!({"excludeDomains": ["example.com"]}),
json!({"excludeDomains": ["example.com"]}),
),
(
"legacy_snake",
json!({
"google_search_retrieval": {
"dynamic_retrieval_config": {
"mode": "MODE_DYNAMIC",
"dynamic_threshold": 0.7
}
}
}),
"google_search_retrieval",
true,
json!({
"dynamicRetrievalConfig": {
"mode": "MODE_DYNAMIC",
"dynamicThreshold": 0.7
}
}),
json!({}),
),
(
"legacy_camel",
json!({
"googleSearchRetrieval": {
"dynamicRetrievalConfig": {
"mode": "MODE_DYNAMIC",
"dynamicThreshold": 0.7
}
}
}),
"googleSearchRetrieval",
true,
json!({
"dynamicRetrievalConfig": {
"mode": "MODE_DYNAMIC",
"dynamicThreshold": 0.7
}
}),
json!({}),
),
];
for (
name,
tool,
source_field,
legacy,
expected_extension_payload,
expected_output_payload,
) in cases
{
let request = json!({
"model": "gemini-2.5-pro",
"contents": [{"role": "user", "parts": [{"text": "search"}]}],
"tools": [tool]
});
let canonical = from_gemini_to_canonical_request(
&request,
"/v1beta/models/gemini-2.5-pro:generateContent",
)
.unwrap_or_else(|| panic!("{name}: canonical request"));
assert_eq!(
canonical
.extensions
.get("openai")
.and_then(|value| value.get("web_search_options")),
Some(&json!({})),
"{name}: web search option"
);
let google_search = canonical
.extensions
.get("gemini")
.and_then(|value| value.get("grounding"))
.and_then(|value| value.get("google_search"))
.unwrap_or_else(|| panic!("{name}: gemini google_search grounding"));
assert_eq!(
google_search.get("source_field").and_then(Value::as_str),
Some(source_field),
"{name}: source field"
);
assert_eq!(
google_search.get("legacy").and_then(Value::as_bool),
Some(legacy),
"{name}: legacy flag"
);
assert_eq!(
google_search.get("payload"),
Some(&expected_extension_payload),
"{name}: normalized payload"
);
let rebuilt =
canonical_to_gemini_request(&canonical, "gemini-upstream", false).unwrap();
assert_eq!(
rebuilt["tools"],
json!([{"googleSearch": expected_output_payload}]),
"{name}: canonical output"
);
}
}
#[test]
fn gemini_request_adapter_keeps_agent_search_retrieval_separate_from_google_search() {
let request = json!({
"model": "gemini-2.5-pro",
"contents": [{"role": "user", "parts": [{"text": "private data"}]}],
"tools": [{
"retrieval": {
"vertexAiSearch": {
"datastore": "projects/p/locations/global/collections/default_collection/dataStores/d"
}
}
}]
});
let canonical = from_gemini_to_canonical_request(
&request,
"/v1beta/models/gemini-2.5-pro:generateContent",
)
.expect("canonical request");
assert_eq!(
canonical
.extensions
.get("openai")
.and_then(|value| value.get("web_search_options")),
None
);
let rebuilt = canonical_to_gemini_request(&canonical, "gemini-upstream", false).unwrap();
assert_eq!(rebuilt["tools"], request["tools"]);
assert!(rebuilt["tools"]
.as_array()
.unwrap()
.iter()
.all(|tool| tool.get("googleSearch").is_none()));
}
#[test]
fn gemini_request_adapter_preserves_combined_search_builtin_tool_fields() {
let cases = [
(
"current_snake",
json!({
"google_search": {},
"code_execution": {},
"url_context": {},
"retrieval": {
"vertexAiSearch": {
"datastore": "projects/p/locations/global/collections/default_collection/dataStores/d"
}
}
}),
),
(
"legacy_snake",
json!({
"google_search_retrieval": {
"dynamic_retrieval_config": {
"mode": "MODE_DYNAMIC",
"dynamic_threshold": 0.7
}
},
"code_execution": {},
"url_context": {}
}),
),
];
for (name, tool) in cases {
let request = json!({
"model": "gemini-2.5-pro",
"contents": [{"role": "user", "parts": [{"text": "search with builtins"}]}],
"tools": [tool]
});
let canonical = from_gemini_to_canonical_request(
&request,
"/v1beta/models/gemini-2.5-pro:generateContent",
)
.unwrap_or_else(|| panic!("{name}: canonical request"));
let rebuilt =
canonical_to_gemini_request(&canonical, "gemini-upstream", false).unwrap();
let tools = rebuilt["tools"]
.as_array()
.unwrap_or_else(|| panic!("{name}: tools array"));
assert!(
tools.iter().any(|tool| tool.get("googleSearch").is_some()),
"{name}: google search should be preserved"
);
assert!(
tools.iter().any(|tool| tool.get("codeExecution").is_some()),
"{name}: code execution should be preserved"
);
assert!(
tools.iter().any(|tool| tool.get("urlContext").is_some()),
"{name}: URL context should be preserved"
);
if name == "current_snake" {
assert!(
tools.iter().any(|tool| tool.get("retrieval").is_some()),
"{name}: unhandled retrieval should be preserved"
);
}
}
}
#[test]
fn gemini_response_adapter_preserves_thought_signature_tool_and_usage() {
let response = json!({
@@ -5913,6 +6436,48 @@ mod tests {
assert_eq!(rebuilt["usageMetadata"]["thoughtsTokenCount"], 2);
}
#[test]
fn gemini_response_adapter_preserves_grounding_metadata() {
let grounding_metadata = json!({
"webSearchQueries": ["query"],
"searchEntryPoint": {"renderedContent": "<style></style>"},
"groundingChunks": [{
"web": {
"uri": "https://example.com",
"title": "Example"
}
}],
"groundingSupports": []
});
let response = json!({
"responseId": "resp_grounded",
"modelVersion": "gemini-2.5-pro",
"candidates": [{
"index": 0,
"finishReason": "STOP",
"groundingMetadata": grounding_metadata,
"content": {
"parts": [{"text": "grounded answer"}]
}
}]
});
let canonical = from_gemini_to_canonical_response(&response).expect("canonical response");
assert_eq!(
canonical.outputs[0]
.extensions
.get("gemini")
.and_then(|value| value.get("groundingMetadata")),
Some(&grounding_metadata)
);
let rebuilt = canonical_to_gemini_response(&canonical, &json!({})).expect("gemini");
assert_eq!(
rebuilt["candidates"][0]["groundingMetadata"],
grounding_metadata
);
}
#[test]
fn canonical_response_preserves_openai_choices_and_gemini_candidates() {
let openai_response = json!({
@@ -47,6 +47,13 @@ pub fn normalize_provider_private_report_context(report_context: Option<&Value>)
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default();
if report_context_preserves_private_client_envelope(
report_context,
envelope_name,
provider_api_format,
) {
return Some(report_context.clone());
}
if provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format).is_none() {
return Some(report_context.clone());
}
@@ -64,6 +71,22 @@ pub fn normalize_provider_private_response_value(
{
return Some(data);
}
let envelope_name = report_context
.get("envelope_name")
.and_then(Value::as_str)
.unwrap_or_default();
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default();
if report_context_preserves_private_client_envelope(
report_context,
envelope_name,
provider_api_format,
) {
return Some(data);
}
let mut unwrapped = match report_context.get("envelope_name").and_then(Value::as_str) {
Some(KIRO_ENVELOPE_NAME) => data,
Some(GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME) => {
@@ -155,6 +178,13 @@ fn transform_provider_private_stream_line_with_event_state(
if !provider_adaptation_should_unwrap_stream_envelope(envelope_name, provider_api_format) {
return Ok(line);
}
if report_context_preserves_private_client_envelope(
report_context,
envelope_name,
provider_api_format,
) {
return Ok(line);
}
if envelope_name == WINDSURF_ENVELOPE_NAME && looks_like_windsurf_error(&body) {
return Ok(line);
}
@@ -305,6 +335,13 @@ pub fn maybe_build_provider_private_stream_normalizer<'a>(
.unwrap_or_default();
let descriptor =
provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format)?;
if report_context_preserves_private_client_envelope(
report_context,
envelope_name,
provider_api_format,
) {
return None;
}
let mode = if descriptor
.envelope_name
.eq_ignore_ascii_case(KIRO_ENVELOPE_NAME)
@@ -679,6 +716,24 @@ fn clear_private_envelope_context(report_context: &Value) -> Value {
normalized
}
fn report_context_preserves_private_client_envelope(
report_context: &Value,
envelope_name: &str,
provider_api_format: &str,
) -> bool {
if envelope_name.is_empty()
|| provider_adaptation_descriptor_for_envelope(envelope_name, provider_api_format).is_none()
{
return false;
}
report_context
.get("client_envelope_name")
.and_then(Value::as_str)
.is_some_and(|client_envelope_name| {
client_envelope_name.eq_ignore_ascii_case(envelope_name)
})
}
fn local_finalize_response_model(report_context: &Value) -> &str {
report_context
.get("mapped_model")
@@ -997,6 +1052,20 @@ mod tests {
assert!(output_text.contains("\"id\":\"call_get_weather_0\""));
}
#[test]
fn private_stream_normalizer_preserves_antigravity_native_client_envelope() {
let report_context = json!({
"has_envelope": true,
"provider_api_format": "gemini:generate_content",
"client_api_format": "gemini:generate_content",
"envelope_name": "antigravity:v1internal",
"client_envelope_name": "antigravity:v1internal",
"mapped_model": "claude-sonnet-4-5",
});
assert!(maybe_build_provider_private_stream_normalizer(Some(&report_context)).is_none());
}
#[test]
fn detects_sse_error_events_without_explicit_type_field() {
let body = br#"event: error