mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 03:09:50 +08:00
Merge origin/main into fix/gemini-cli-v1internal
This commit is contained in:
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user