Merge remote-tracking branch 'origin/main' into fix/gemini-cli-v1internal

# Conflicts:
#	apps/aether-gateway/src/ai_serving/transport.rs
#	apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/parse.rs
#	apps/aether-gateway/src/handlers/shared/catalog.rs
#	crates/aether-admin/src/provider/quota.rs
#	crates/aether-model-fetch/src/strategy.rs
#	crates/aether-provider-pool/src/lib.rs
#	crates/aether-provider-pool/src/service.rs
#	crates/aether-provider-transport/src/provider_types.rs
#	frontend/src/features/providers/components/ProviderDetailDrawer.vue
#	frontend/src/utils/__tests__/providerKeyQuota.spec.ts
#	frontend/src/utils/providerKeyQuota.ts
#	frontend/src/views/admin/PoolManagement.vue
This commit is contained in:
Mas0nShi
2026-05-22 17:13:57 +08:00
220 changed files with 30324 additions and 2776 deletions
@@ -275,6 +275,10 @@ mod tests {
"gemini:generate_content"
));
assert!(!api_format_alias_matches("openai:cli", "openai:responses"));
assert_eq!(
normalize_api_format_alias("openai:compact"),
"openai:compact"
);
}
#[test]
@@ -1180,6 +1180,23 @@ impl OpenAIResponsesProviderState {
}
}
}
event_type if openai_stream_payload_is_terminal_error(&value) => {
self.finished = true;
let mut payload = value.clone();
if event_type != "response.failed"
&& event_type != "response.incomplete"
&& event_type != "error"
{
payload = openai_stream_terminal_error_body(&value).unwrap_or(payload);
if let Some(object) = payload.as_object_mut() {
object.insert(
"type".to_string(),
Value::String("response.failed".to_string()),
);
}
}
out.push(self.unknown_frame(report_context, payload));
}
"response.completed" => {
let Some(response) = value.get("response").and_then(Value::as_object) else {
return Ok(out);
@@ -1291,6 +1308,8 @@ pub struct OpenAIChatClientEmitter {
model: Option<String>,
started: bool,
finished: bool,
next_tool_call_index: usize,
tool_call_index_by_canonical: BTreeMap<usize, usize>,
}
#[derive(Clone, Default)]
@@ -1299,6 +1318,7 @@ struct OpenAIResponsesClientToolState {
name: String,
arguments: String,
output_index: Option<usize>,
web_search: bool,
}
#[derive(Clone, Default)]
@@ -1310,6 +1330,23 @@ struct OpenAIResponsesClientToolResultState {
item_started: bool,
}
fn is_responses_web_search_tool(name: &str) -> bool {
matches!(name, "web_search" | "web_search_preview")
}
fn web_search_query_from_arguments(arguments: &str) -> String {
serde_json::from_str::<Value>(arguments)
.ok()
.and_then(|value| {
value
.get("query")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| value.as_str().map(ToOwned::to_owned))
})
.unwrap_or_default()
}
#[derive(Default)]
pub struct OpenAIResponsesClientEmitter {
response_id: Option<String>,
@@ -1357,6 +1394,17 @@ impl OpenAIChatClientEmitter {
)
}
fn chat_tool_call_index(&mut self, canonical_index: usize) -> usize {
if let Some(index) = self.tool_call_index_by_canonical.get(&canonical_index) {
return *index;
}
let index = self.next_tool_call_index;
self.next_tool_call_index += 1;
self.tool_call_index_by_canonical
.insert(canonical_index, index);
index
}
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
self.update_identity(&frame);
match frame.event {
@@ -1465,6 +1513,7 @@ impl OpenAIChatClientEmitter {
name,
} => {
let mut out = self.ensure_started()?;
let chat_index = self.chat_tool_call_index(index);
out.extend(encode_json_sse(
None,
&build_openai_chat_chunk(
@@ -1474,7 +1523,7 @@ impl OpenAIChatClientEmitter {
self.model.as_deref().unwrap_or("unknown"),
String::new(),
Some(vec![json!({
"index": index,
"index": chat_index,
"id": call_id,
"type": "function",
"function": {
@@ -1489,6 +1538,7 @@ impl OpenAIChatClientEmitter {
}
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
let mut out = self.ensure_started()?;
let chat_index = self.chat_tool_call_index(index);
out.extend(encode_json_sse(
None,
&json!({
@@ -1501,7 +1551,7 @@ impl OpenAIChatClientEmitter {
"index": 0,
"delta": {
"tool_calls": [{
"index": index,
"index": chat_index,
"function": {
"arguments": arguments,
}
@@ -1544,6 +1594,13 @@ impl OpenAIChatClientEmitter {
)?);
Ok(out)
}
CanonicalStreamEvent::UnknownEvent(payload)
if openai_stream_terminal_error_body(&payload).is_some() =>
{
self.finished = true;
let error_body = openai_stream_terminal_error_body(&payload).unwrap_or(payload);
encode_json_sse(None, &error_body)
}
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
CanonicalStreamEvent::Finish {
finish_reason,
@@ -1985,6 +2042,26 @@ impl OpenAIResponsesClientEmitter {
} else {
state.name.clone()
};
if state.web_search {
out.extend(self.encode_response_event(
"response.output_item.done",
json!({
"type": "response.output_item.done",
"response_id": self.response_id(),
"output_index": output_index,
"item": {
"type": "web_search_call",
"id": item_id,
"status": "completed",
"action": {
"type": "search",
"query": web_search_query_from_arguments(&state.arguments),
},
}
}),
)?);
continue;
}
out.extend(self.encode_response_event(
"response.function_call_arguments.done",
json!({
@@ -2143,20 +2220,32 @@ impl OpenAIResponsesClientEmitter {
}
for (index, state) in &self.tool_calls {
if let Some(output_index) = state.output_index {
let item_id = if state.call_id.is_empty() {
build_generated_tool_call_id(*index)
} else {
state.call_id.clone()
};
if state.web_search {
ordered_output.push((
output_index,
json!({
"type": "web_search_call",
"id": item_id,
"status": "completed",
"action": {
"type": "search",
"query": web_search_query_from_arguments(&state.arguments),
},
}),
));
continue;
}
ordered_output.push((
output_index,
json!({
"type": "function_call",
"id": if state.call_id.is_empty() {
build_generated_tool_call_id(*index)
} else {
state.call_id.clone()
},
"call_id": if state.call_id.is_empty() {
build_generated_tool_call_id(*index)
} else {
state.call_id.clone()
},
"id": item_id.clone(),
"call_id": item_id,
"name": if state.name.is_empty() {
"unknown".to_string()
} else {
@@ -2322,22 +2411,36 @@ impl OpenAIResponsesClientEmitter {
let state = self.tool_calls.entry(index).or_default();
state.call_id = call_id.clone();
state.name = name.clone();
state.web_search = is_responses_web_search_tool(&name);
let emitted_call_id = state.call_id.clone();
let emitted_name = state.name.clone();
let item = if state.web_search {
json!({
"type": "web_search_call",
"id": emitted_call_id,
"status": "in_progress",
"action": {
"type": "search",
"query": "",
},
})
} else {
json!({
"type": "function_call",
"id": call_id,
"call_id": emitted_call_id,
"name": emitted_name,
"arguments": "",
"status": "in_progress",
})
};
out.extend(self.encode_response_event(
"response.output_item.added",
json!({
"type": "response.output_item.added",
"response_id": response_id,
"output_index": output_index,
"item": {
"type": "function_call",
"id": call_id,
"call_id": emitted_call_id,
"name": emitted_name,
"arguments": "",
"status": "in_progress",
}
"item": item
}),
)?);
Ok(out)
@@ -2348,6 +2451,9 @@ impl OpenAIResponsesClientEmitter {
let response_id = self.response_id().to_string();
let state = self.tool_calls.entry(index).or_default();
state.arguments.push_str(&arguments);
if state.web_search {
return Ok(out);
}
let item_id = if state.call_id.is_empty() {
build_generated_tool_call_id(index)
} else {
@@ -2441,6 +2547,29 @@ impl OpenAIResponsesClientEmitter {
}
Ok(out)
}
CanonicalStreamEvent::UnknownEvent(payload)
if openai_stream_terminal_error_body(&payload).is_some() =>
{
self.finished = true;
let raw_event = payload.get("type").and_then(Value::as_str);
let event = raw_event
.filter(|event| {
matches!(*event, "response.failed" | "response.incomplete" | "error")
})
.unwrap_or("response.failed")
.to_string();
let mut payload = if raw_event == Some(event.as_str()) {
payload
} else {
openai_stream_terminal_error_body(&payload).unwrap_or(payload)
};
if payload.get("type").is_none() {
if let Some(object) = payload.as_object_mut() {
object.insert("type".to_string(), Value::String(event.clone()));
}
}
self.encode_response_event(event.as_str(), payload)
}
CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()),
CanonicalStreamEvent::Finish { usage, .. } => {
if self.finished {
@@ -2588,6 +2717,27 @@ mod tests {
parts
}
fn openai_chat_tool_call_indices(sse: &str) -> Vec<u64> {
let mut indices = Vec::new();
for payload in sse.lines().filter_map(|line| line.strip_prefix("data: ")) {
let Ok(value) = serde_json::from_str::<Value>(payload) else {
continue;
};
let Some(tool_calls) = value
.pointer("/choices/0/delta/tool_calls")
.and_then(Value::as_array)
else {
continue;
};
for tool_call in tool_calls {
if let Some(index) = tool_call.get("index").and_then(Value::as_u64) {
indices.push(index);
}
}
}
indices
}
#[test]
fn openai_chat_provider_state_emits_unknown_events_for_unrecognized_deltas() {
let mut state = OpenAIChatProviderState::default();
@@ -2646,6 +2796,40 @@ mod tests {
)));
}
#[test]
fn openai_responses_provider_state_treats_failed_event_as_terminal() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let frames = state
.push_line(
&report_context,
data_line(json!({
"type": "response.failed",
"response": {
"id": "resp_failed_123",
"model": "gpt-5.4",
"status": "failed",
"error": {
"message": "policy failure",
"type": "invalid_request_error",
"code": "cyber_policy"
}
}
})),
)
.expect("failed response event should parse");
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::UnknownEvent(ref payload)
if payload.get("type").and_then(Value::as_str) == Some("response.failed")
)));
assert!(state
.finish(&report_context)
.expect("terminal failure should not synthesize completion")
.is_empty());
}
#[test]
fn openai_usage_derives_missing_input_tokens_from_total() {
let usage = canonical_usage_from_openai_usage(Some(&json!({
@@ -2812,6 +2996,41 @@ mod tests {
assert_eq!(response_sequence_numbers(&sse), (1..=9).collect::<Vec<_>>());
}
#[test]
fn openai_responses_client_emitter_forwards_failed_unknown_event() {
let mut emitter = OpenAIResponsesClientEmitter::default();
let bytes = emitter
.emit(CanonicalStreamFrame {
id: "resp_failed_123".to_string(),
model: "gpt-5.4".to_string(),
event: CanonicalStreamEvent::UnknownEvent(json!({
"type": "response.failed",
"response": {
"id": "resp_failed_123",
"model": "gpt-5.4",
"status": "failed",
"error": {
"message": "policy failure",
"type": "invalid_request_error",
"code": "cyber_policy"
}
}
})),
})
.expect("failed response event should encode");
let mut all = bytes;
all.extend(
emitter
.finish()
.expect("failed stream should not synthesize completion"),
);
let sse = String::from_utf8(all).expect("sse should be utf8");
assert!(sse.contains("event: response.failed\n"));
assert!(sse.contains("\"message\":\"policy failure\""));
assert!(!sse.contains("event: response.completed\n"));
}
#[test]
fn openai_responses_client_emitter_keeps_text_item_id_stable_after_text_started() {
let mut emitter = OpenAIResponsesClientEmitter::default();
@@ -3167,6 +3386,56 @@ mod tests {
assert!(sse.contains("\"output\":\"{\\\"ok\\\":true}\""));
}
#[test]
fn openai_responses_client_emitter_emits_web_search_call_item() {
let mut emitter = OpenAIResponsesClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "resp_123".to_string(),
model: "gpt-5-5-low".to_string(),
event: CanonicalStreamEvent::ToolCallStart {
index: 0,
call_id: "call_ws_1".to_string(),
name: "web_search".to_string(),
},
})
.expect("tool start should encode");
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_123".to_string(),
model: "gpt-5-5-low".to_string(),
event: CanonicalStreamEvent::ToolCallArgumentsDelta {
index: 0,
arguments: r#"{"query":"today tech"}"#.to_string(),
},
})
.expect("arguments should encode"),
);
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_123".to_string(),
model: "gpt-5-5-low".to_string(),
event: CanonicalStreamEvent::Finish {
finish_reason: Some("tool_calls".to_string()),
usage: None,
},
})
.expect("finish should encode"),
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("event: response.output_item.added\n"));
assert!(sse.contains(r#""type":"web_search_call""#));
assert!(sse.contains(r#""status":"in_progress""#));
assert!(sse.contains(r#""query":"""#));
assert!(sse.contains(r#""type":"search""#));
assert!(sse.contains("event: response.output_item.done\n"));
assert!(sse.contains(r#""query":"today tech""#));
assert!(!sse.contains("response.function_call_arguments.delta"));
}
#[test]
fn openai_responses_provider_state_accepts_legacy_outtext_delta_alias() {
let mut state = OpenAIResponsesProviderState::default();
@@ -3250,6 +3519,50 @@ mod tests {
assert!(sse.contains("[Image]"));
}
#[test]
fn openai_chat_client_emitter_normalizes_sparse_tool_call_indices() {
let mut emitter = OpenAIChatClientEmitter::default();
let mut bytes = Vec::new();
for event in [
CanonicalStreamEvent::ToolCallStart {
index: 1,
call_id: "call_first".to_string(),
name: "first_tool".to_string(),
},
CanonicalStreamEvent::ToolCallArgumentsDelta {
index: 1,
arguments: "{\"first\":".to_string(),
},
CanonicalStreamEvent::ToolCallStart {
index: 3,
call_id: "call_second".to_string(),
name: "second_tool".to_string(),
},
CanonicalStreamEvent::ToolCallArgumentsDelta {
index: 3,
arguments: "{\"second\":true}".to_string(),
},
CanonicalStreamEvent::ToolCallArgumentsDelta {
index: 1,
arguments: "true}".to_string(),
},
] {
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "chatcmpl_sparse".to_string(),
model: "claude-opus-4-6".to_string(),
event,
})
.expect("tool event should encode"),
);
}
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert_eq!(openai_chat_tool_call_indices(&sse), vec![0, 0, 1, 1, 0]);
}
#[test]
fn openai_chat_client_emitter_emits_usage_only_final_chunk() {
let mut emitter = OpenAIChatClientEmitter::default();
@@ -165,14 +165,25 @@ fn inject_codex_default_variation_prompt(body_object: &mut serde_json::Map<Strin
);
}
fn build_stable_codex_prompt_cache_key(user_api_key_id: &str) -> Option<String> {
let normalized = user_api_key_id.trim();
fn build_stable_codex_prompt_cache_key_from_seed(kind: &str, seed: &str) -> Option<String> {
let normalized = seed.trim();
if normalized.is_empty() {
return None;
}
let normalized_kind = kind
.trim()
.to_ascii_lowercase()
.chars()
.filter(|ch| ch.is_ascii_alphanumeric() || *ch == '_' || *ch == '-')
.collect::<String>();
let normalized_kind = if normalized_kind.is_empty() {
"seed".to_string()
} else {
normalized_kind
};
let namespace = format!(
"aether:codex:prompt-cache:{CODEX_PROMPT_CACHE_NAMESPACE_VERSION}:user:{normalized}"
"aether:codex:prompt-cache:{CODEX_PROMPT_CACHE_NAMESPACE_VERSION}:{normalized_kind}:{normalized}"
);
let mut hasher = Sha1::new();
hasher.update(UUID_NAMESPACE_OID_BYTES);
@@ -186,6 +197,266 @@ fn build_stable_codex_prompt_cache_key(user_api_key_id: &str) -> Option<String>
Some(Uuid::from_bytes(bytes).to_string())
}
fn build_stable_codex_prompt_cache_key(user_api_key_id: &str) -> Option<String> {
build_stable_codex_prompt_cache_key_from_seed("user", user_api_key_id)
}
fn extract_codex_prompt_cache_session_seed(provider_request_body: &Value) -> Option<String> {
fn non_empty_str(value: Option<&Value>) -> Option<&str> {
value
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn session_seed_from_metadata(metadata: &Value) -> Option<String> {
let object = metadata.as_object()?;
non_empty_str(object.get("session_id"))
.or_else(|| non_empty_str(object.get("sessionId")))
.or_else(|| non_empty_str(object.get("conversation_id")))
.or_else(|| non_empty_str(object.get("conversationId")))
.map(|value| format!("metadata:{value}"))
.or_else(|| {
let user_id = non_empty_str(object.get("user_id"))?;
serde_json::from_str::<Value>(user_id)
.ok()
.and_then(|decoded| {
non_empty_str(decoded.get("session_id"))
.or_else(|| non_empty_str(decoded.get("sessionId")))
.or_else(|| non_empty_str(decoded.get("conversation_id")))
.or_else(|| non_empty_str(decoded.get("conversationId")))
.map(|value| format!("metadata.user_id:{value}"))
})
})
}
let object = provider_request_body.as_object()?;
non_empty_str(object.get("session_id"))
.or_else(|| non_empty_str(object.get("sessionId")))
.or_else(|| non_empty_str(object.get("conversation_id")))
.or_else(|| non_empty_str(object.get("conversationId")))
.map(|value| format!("body:{value}"))
.or_else(|| object.get("metadata").and_then(session_seed_from_metadata))
}
fn sha256_hex(input: &[u8]) -> String {
let digest = Sha256::digest(input);
let mut output = String::with_capacity(digest.len() * 2);
for byte in digest {
let _ = write!(&mut output, "{byte:02x}");
}
output
}
fn stable_json_digest(value: &Value) -> Option<String> {
serde_json::to_vec(value)
.ok()
.map(|serialized| sha256_hex(&serialized))
}
fn compact_prompt_cache_text(value: &str) -> Option<Value> {
const MAX_PROMPT_CACHE_TEXT_CHARS: usize = 4096;
let normalized = value.trim();
if normalized.is_empty() {
return None;
}
let mut text = normalized
.chars()
.take(MAX_PROMPT_CACHE_TEXT_CHARS)
.collect::<String>();
if normalized.chars().count() > MAX_PROMPT_CACHE_TEXT_CHARS {
text.push_str("...");
}
Some(Value::String(text))
}
fn compact_prompt_cache_anchor(value: &Value) -> Value {
match value {
Value::String(text) => compact_prompt_cache_text(text).unwrap_or(Value::Null),
Value::Array(items) => Value::Array(
items
.iter()
.take(16)
.map(compact_prompt_cache_anchor)
.filter(|value| !value.is_null())
.collect(),
),
Value::Object(object) => {
let mut compacted = serde_json::Map::new();
for key in [
"type",
"role",
"id",
"name",
"description",
"text",
"input_text",
"output_text",
"content",
"call_id",
"arguments",
"output",
"parameters",
"strict",
"function",
"effort",
"summary",
] {
let Some(value) = object.get(key) else {
continue;
};
let value = compact_prompt_cache_anchor(value);
if !value.is_null() {
compacted.insert(key.to_string(), value);
}
}
Value::Object(compacted)
}
Value::Null | Value::Bool(_) | Value::Number(_) => value.clone(),
}
}
fn compact_prompt_cache_json_anchor(value: &Value) -> Value {
match value {
Value::String(text) => compact_prompt_cache_text(text).unwrap_or(Value::Null),
Value::Array(items) => Value::Array(
items
.iter()
.take(16)
.map(compact_prompt_cache_json_anchor)
.filter(|value| !value.is_null())
.collect(),
),
Value::Object(object) => {
let mut compacted = serde_json::Map::new();
let mut keys = object.keys().collect::<Vec<_>>();
keys.sort();
for key in keys {
if key == "cache_control" {
continue;
}
let Some(value) = object.get(key) else {
continue;
};
let value = compact_prompt_cache_json_anchor(value);
if !value.is_null() {
compacted.insert(key.clone(), value);
}
}
Value::Object(compacted)
}
Value::Null | Value::Bool(_) | Value::Number(_) => value.clone(),
}
}
fn collect_codex_prompt_cache_control_anchors(value: &Value, anchors: &mut Vec<Value>) {
const MAX_PROMPT_CACHE_CONTROL_ANCHORS: usize = 16;
if anchors.len() >= MAX_PROMPT_CACHE_CONTROL_ANCHORS {
return;
}
match value {
Value::Object(object) => {
if object.contains_key("cache_control") {
let mut anchor = object.clone();
anchor.remove("cache_control");
let anchor = compact_prompt_cache_anchor(&Value::Object(anchor));
if !anchor.is_null() {
anchors.push(anchor);
}
}
for child in object.values() {
if anchors.len() >= MAX_PROMPT_CACHE_CONTROL_ANCHORS {
break;
}
collect_codex_prompt_cache_control_anchors(child, anchors);
}
}
Value::Array(items) => {
for child in items {
if anchors.len() >= MAX_PROMPT_CACHE_CONTROL_ANCHORS {
break;
}
collect_codex_prompt_cache_control_anchors(child, anchors);
}
}
_ => {}
}
}
fn extract_codex_prompt_cache_control_seed(provider_request_body: &Value) -> Option<String> {
let mut anchors = Vec::new();
collect_codex_prompt_cache_control_anchors(provider_request_body, &mut anchors);
if anchors.is_empty() {
return None;
}
let seed = json!({
"model": provider_request_body.get("model"),
"anchors": anchors,
});
stable_json_digest(&seed).map(|digest| format!("cache_control:{digest}"))
}
fn first_responses_input_anchor(input: &Value) -> Option<Value> {
let items = input.as_array()?;
let first_user_message = items.iter().find(|item| {
item.get("type")
.and_then(Value::as_str)
.is_some_and(|value| value == "message")
&& item
.get("role")
.and_then(Value::as_str)
.is_some_and(|value| value == "user")
});
let first_item = first_user_message.or_else(|| items.first())?;
let anchor = compact_prompt_cache_anchor(first_item);
(!anchor.is_null()).then_some(anchor)
}
fn extract_codex_stable_request_prompt_cache_seed(
provider_request_body: &Value,
user_api_key_id: Option<&str>,
) -> Option<String> {
let object = provider_request_body.as_object()?;
let mut seed = serde_json::Map::new();
for key in [
"model",
"instructions",
"reasoning",
"tools",
"tool_choice",
"parallel_tool_calls",
] {
if let Some(value) = object.get(key).filter(|value| !value.is_null()) {
let value = if key == "tools" {
compact_prompt_cache_json_anchor(value)
} else {
compact_prompt_cache_anchor(value)
};
seed.insert(key.to_string(), value);
}
}
if let Some(input_anchor) = object.get("input").and_then(first_responses_input_anchor) {
seed.insert("first_input".to_string(), input_anchor);
}
if let Some(user_api_key_id) = user_api_key_id
.map(str::trim)
.filter(|value| !value.is_empty())
{
seed.insert(
"api_key_id".to_string(),
Value::String(user_api_key_id.to_string()),
);
}
if seed.len() < 2 {
return None;
}
stable_json_digest(&Value::Object(seed)).map(|digest| format!("stable_request:{digest}"))
}
fn build_short_codex_header_id(seed: &str) -> Option<String> {
let normalized = seed.trim();
if normalized.is_empty() {
@@ -261,31 +532,47 @@ fn maybe_insert_default_codex_header(
provider_request_headers.insert(header_name.to_string(), header_value.to_string());
}
fn maybe_inject_codex_prompt_cache_key(
provider_request_body: &mut Value,
fn codex_prompt_cache_key_to_insert(
provider_request_body: &Value,
provider_type: &str,
provider_api_format: &str,
user_api_key_id: Option<&str>,
) {
) -> Option<String> {
if !is_codex_openai_responses_request(provider_type, provider_api_format) {
return;
return None;
}
let Some(body_object) = provider_request_body.as_object_mut() else {
return;
};
let existing = body_object
let existing = provider_request_body
.get("prompt_cache_key")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if !existing.is_empty() {
return;
return None;
}
let Some(prompt_cache_key) = user_api_key_id.and_then(build_stable_codex_prompt_cache_key)
else {
extract_codex_prompt_cache_session_seed(provider_request_body)
.and_then(|seed| build_stable_codex_prompt_cache_key_from_seed("session", &seed))
.or_else(|| {
extract_codex_prompt_cache_control_seed(provider_request_body)
.and_then(|seed| build_stable_codex_prompt_cache_key_from_seed("anchor", &seed))
})
.or_else(|| {
extract_codex_stable_request_prompt_cache_seed(provider_request_body, user_api_key_id)
.and_then(|seed| build_stable_codex_prompt_cache_key_from_seed("request", &seed))
})
.or_else(|| user_api_key_id.and_then(build_stable_codex_prompt_cache_key))
}
fn insert_codex_prompt_cache_key(
provider_request_body: &mut Value,
prompt_cache_key: Option<String>,
) {
let Some(prompt_cache_key) = prompt_cache_key else {
return;
};
let Some(body_object) = provider_request_body.as_object_mut() else {
return;
};
@@ -431,6 +718,13 @@ pub fn apply_codex_openai_responses_special_body_edits(
return;
}
let prompt_cache_key = codex_prompt_cache_key_to_insert(
provider_request_body,
provider_type,
provider_api_format,
user_api_key_id,
);
let Some(body_object) = provider_request_body.as_object_mut() else {
return;
};
@@ -479,12 +773,7 @@ pub fn apply_codex_openai_responses_special_body_edits(
inject_codex_default_variation_prompt(body_object);
}
maybe_inject_codex_prompt_cache_key(
provider_request_body,
provider_type,
provider_api_format,
user_api_key_id,
);
insert_codex_prompt_cache_key(provider_request_body, prompt_cache_key);
}
pub fn apply_codex_openai_responses_chat_body_edits(
@@ -509,6 +798,9 @@ pub fn apply_codex_openai_responses_chat_body_edits(
return;
};
ensure_codex_chat_reasoning_defaults(body_object, provider_api_format, body_rules);
if let Some(prompt_cache_key) = body_object.remove("prompt_cache_key") {
body_object.insert("prompt_cache_key".to_string(), prompt_cache_key);
}
}
pub fn apply_codex_openai_responses_special_headers(
@@ -748,6 +1040,208 @@ mod tests {
);
}
#[test]
fn codex_responses_body_edits_derive_prompt_cache_key_from_session_metadata() {
let mut body_a = json!({
"input": [{"role": "user", "content": "hello"}],
"model": "gpt-5.4",
"metadata": {
"user_id": "{\"session_id\":\"session-a\",\"device_id\":\"device-a\"}"
}
});
let mut body_b = json!({
"input": [{"role": "user", "content": "hello again"}],
"model": "gpt-5.4",
"metadata": {
"user_id": "{\"session_id\":\"session-a\",\"device_id\":\"device-b\"}"
}
});
let mut body_c = json!({
"input": [{"role": "user", "content": "hello"}],
"model": "gpt-5.4",
"metadata": {"session_id": "session-b"}
});
apply_codex_openai_responses_special_body_edits(
&mut body_a,
"codex",
"openai:responses",
None,
Some("key-123"),
);
apply_codex_openai_responses_special_body_edits(
&mut body_b,
"codex",
"openai:responses",
None,
Some("different-key"),
);
apply_codex_openai_responses_special_body_edits(
&mut body_c,
"codex",
"openai:responses",
None,
Some("key-123"),
);
assert_eq!(body_a["prompt_cache_key"], body_b["prompt_cache_key"]);
assert_ne!(body_a["prompt_cache_key"], body_c["prompt_cache_key"]);
assert!(body_a.get("metadata").is_none());
assert!(body_b.get("metadata").is_none());
assert!(body_c.get("metadata").is_none());
}
#[test]
fn codex_responses_body_edits_derive_prompt_cache_key_from_cache_control_anchor() {
let mut body_a = json!({
"input": [{
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "stable project brief",
"cache_control": {"type": "ephemeral"}
}]
}, {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "new turn A"}]
}],
"model": "gpt-5.4"
});
let mut body_b = json!({
"input": [{
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "stable project brief",
"cache_control": {"type": "ephemeral"}
}]
}, {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "new turn B"}]
}],
"model": "gpt-5.4"
});
let mut body_c = json!({
"input": [{
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "different project brief",
"cache_control": {"type": "ephemeral"}
}]
}],
"model": "gpt-5.4"
});
apply_codex_openai_responses_special_body_edits(
&mut body_a,
"codex",
"openai:responses",
None,
Some("key-a"),
);
apply_codex_openai_responses_special_body_edits(
&mut body_b,
"codex",
"openai:responses",
None,
Some("key-b"),
);
apply_codex_openai_responses_special_body_edits(
&mut body_c,
"codex",
"openai:responses",
None,
Some("key-a"),
);
assert_eq!(body_a["prompt_cache_key"], body_b["prompt_cache_key"]);
assert_ne!(body_a["prompt_cache_key"], body_c["prompt_cache_key"]);
}
#[test]
fn codex_responses_body_edits_derive_prompt_cache_key_from_stable_request_anchor() {
let mut body_a = json!({
"input": [{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "open workspace"}]
}, {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "new turn A"}]
}],
"model": "gpt-5.4",
"instructions": "Be concise.",
"tools": [{
"type": "function",
"name": "shell",
"parameters": {"type": "object", "properties": {}}
}],
"reasoning": {"effort": "medium"}
});
let mut body_b = json!({
"input": [{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "open workspace"}]
}, {
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "new turn B"}]
}],
"model": "gpt-5.4",
"instructions": "Be concise.",
"tools": [{
"type": "function",
"name": "shell",
"parameters": {"type": "object", "properties": {}}
}],
"reasoning": {"effort": "medium"}
});
let mut body_c = json!({
"input": [{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "open another workspace"}]
}],
"model": "gpt-5.4",
"instructions": "Be concise.",
"tools": [{"type": "function", "name": "shell"}],
"reasoning": {"effort": "medium"}
});
apply_codex_openai_responses_special_body_edits(
&mut body_a,
"codex",
"openai:responses",
None,
Some("key-a"),
);
apply_codex_openai_responses_special_body_edits(
&mut body_b,
"codex",
"openai:responses",
None,
Some("key-a"),
);
apply_codex_openai_responses_special_body_edits(
&mut body_c,
"codex",
"openai:responses",
None,
Some("key-a"),
);
assert_eq!(body_a["prompt_cache_key"], body_b["prompt_cache_key"]);
assert_ne!(body_a["prompt_cache_key"], body_c["prompt_cache_key"]);
}
#[test]
fn compact_body_edits_strip_include_store_and_stream() {
let mut provider_request_body = json!({
@@ -166,13 +166,25 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
&response_id,
&mut message_index,
);
output.push(json!({
"type": "function_call",
"id": id,
"call_id": id,
"name": name,
"arguments": canonicalize_tool_arguments(input),
}));
if is_responses_web_search_tool(name) {
output.push(json!({
"type": "web_search_call",
"id": id,
"status": "completed",
"action": {
"type": "search",
"query": web_search_query_from_value(input),
},
}));
} else {
output.push(json!({
"type": "function_call",
"id": id,
"call_id": id,
"name": name,
"arguments": canonicalize_tool_arguments(input),
}));
}
}
CanonicalContentBlock::ToolResult {
tool_use_id,
@@ -325,3 +337,73 @@ fn openai_responses_output_format_from_mime_type(mime_type: &str) -> String {
}
.to_string()
}
fn is_responses_web_search_tool(name: &str) -> bool {
matches!(name, "web_search" | "web_search_preview")
}
fn web_search_query_from_value(input: &Value) -> String {
input
.get("query")
.and_then(Value::as_str)
.or_else(|| input.as_str())
.unwrap_or_default()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn responses_response_builder_emits_web_search_call_for_web_search_tool_use() {
let response = CanonicalResponse {
id: "resp_test".to_string(),
model: "gpt-5-5-low".to_string(),
content: vec![CanonicalContentBlock::ToolUse {
id: "call_ws_1".to_string(),
name: "web_search".to_string(),
input: json!({"query": "today tech"}),
extensions: BTreeMap::new(),
}],
outputs: Vec::new(),
stop_reason: Some(CanonicalStopReason::ToolUse),
usage: None,
extensions: BTreeMap::new(),
};
let body = to_raw(&response, &json!({}), false);
assert_eq!(body["output"][0]["type"], "web_search_call");
assert_eq!(body["output"][0]["id"], "call_ws_1");
assert_eq!(body["output"][0]["status"], "completed");
assert_eq!(body["output"][0]["action"]["type"], "search");
assert_eq!(body["output"][0]["action"]["query"], "today tech");
}
#[test]
fn responses_response_parser_reads_web_search_call_as_tool_use() {
let body = json!({
"id": "resp_test",
"model": "gpt-5-5-low",
"status": "incomplete",
"output": [{
"type": "web_search_call",
"id": "call_ws_1",
"status": "completed",
"action": {"type": "search", "query": "today tech"}
}]
});
let canonical = from_raw(&body).expect("response should parse");
assert!(
matches!(canonical.content.first(), Some(CanonicalContentBlock::ToolUse {
id,
name,
input,
..
}) if id == "call_ws_1" && name == "web_search" && input["query"] == "today tech")
);
}
}
@@ -2,6 +2,14 @@ use base64::Engine as _;
use crate::formats::id::api_format_uses_body_stream_field;
/// JSON key under which `upstream_is_stream` is written into the AI execution
/// report context and propagated into usage metadata. Shared by the producer
/// (`aether-ai-serving::report_context`) and every downstream consumer so that
/// renames cannot silently desync them — a string-literal mismatch here would
/// degrade to default values (e.g. assuming streaming) without any compile-time
/// signal.
pub const UPSTREAM_IS_STREAM_KEY: &str = "upstream_is_stream";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UpstreamStreamPolicy {
Auto,
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
use serde_json::Value;
use crate::contracts::core_success_background_report_kind;
use crate::formats::shared::request::UPSTREAM_IS_STREAM_KEY;
#[derive(Debug, Clone, PartialEq)]
pub struct LocalSyncReportParts {
@@ -66,7 +67,7 @@ fn should_capture_client_sync_success_body(payload: &LocalSyncReportParts) -> bo
.report_context
.as_ref()
.and_then(Value::as_object)
.and_then(|context| context.get("upstream_is_stream"))
.and_then(|context| context.get(UPSTREAM_IS_STREAM_KEY))
.and_then(Value::as_bool)
.unwrap_or(false)
}
@@ -17,7 +17,10 @@ use crate::formats::openai::responses::codex::{
apply_codex_openai_responses_chat_body_edits, apply_codex_openai_responses_special_body_edits,
apply_openai_responses_compact_special_body_edits,
};
use crate::formats::shared::standard_normalize::build_local_openai_chat_request_body_with_model_directives;
use crate::formats::shared::standard_normalize::{
build_local_openai_chat_request_body_with_model_directives,
is_claude_messages_shaped_body_on_openai_chat_endpoint,
};
#[allow(clippy::too_many_arguments)]
pub fn build_standard_request_body(
@@ -91,9 +94,14 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers(
.with_mapped_model(mapped_model)
.with_request_path(request_path)
.with_upstream_stream(upstream_is_stream);
let mut provider_request_body = convert_request(
let source_api_format = compatible_source_format_for_standard_request(
body_json,
client_api_format,
provider_api_format,
);
let mut provider_request_body = convert_request(
source_api_format.as_ref(),
provider_api_format,
body_json,
&format_context,
)
@@ -157,6 +165,24 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers(
Some(provider_request_body)
}
fn compatible_source_format_for_standard_request<'a>(
body_json: &Value,
client_api_format: &'a str,
provider_api_format: &str,
) -> Cow<'a, str> {
if matches!(
aether_ai_formats::normalize_api_format_alias(client_api_format).as_str(),
"openai:chat"
) && matches!(
aether_ai_formats::normalize_api_format_alias(provider_api_format).as_str(),
"claude:messages"
) && is_claude_messages_shaped_body_on_openai_chat_endpoint(body_json)
{
return Cow::Borrowed("claude:messages");
}
Cow::Borrowed(client_api_format)
}
pub fn build_standard_request_body_from_canonical(
canonical_request: &Value,
mapped_model: &str,
@@ -444,6 +470,87 @@ mod tests {
}
}
#[test]
fn standard_openai_chat_to_claude_accepts_claude_native_body_from_chat_endpoint() {
let request = json!({
"model": "deepseek-v4-flash",
"messages": [
{"role": "user", "content": "lookup"},
{
"role": "assistant",
"content": [
{"type": "text", "text": "checking"},
{
"type": "tool_use",
"id": "call_1",
"name": "lookup",
"input": {"q": "db"}
}
]
},
{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "call_1",
"content": {"rows": 1},
"is_error": false
}]
}
],
"tools": [{
"name": "lookup",
"description": "Lookup data",
"input_schema": {"type": "object", "properties": {"q": {"type": "string"}}}
}],
"tool_choice": {"type": "auto"},
"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("claude-native chat endpoint body should build as claude messages");
assert_eq!(converted["model"], "claude-sonnet-4-5");
assert_eq!(converted["max_tokens"], 128);
assert_eq!(converted["tools"][0]["name"], "lookup");
assert_eq!(
converted["tools"][0]["input_schema"]["properties"]["q"]["type"],
"string"
);
assert_eq!(converted["messages"][1]["content"][1]["type"], "tool_use");
assert_eq!(converted["messages"][1]["content"][1]["id"], "call_1");
assert_eq!(
converted["messages"][2]["content"][0]["type"],
"tool_result"
);
assert_eq!(
converted["messages"][2]["content"][0]["tool_use_id"],
"call_1"
);
assert_eq!(
serde_json::from_str::<Value>(
converted["messages"][2]["content"][0]["content"]
.as_str()
.expect("object tool result content should be serialized for Claude")
)
.expect("serialized tool result content should remain JSON"),
json!({"rows": 1})
);
assert_eq!(converted["tool_choice"]["type"], "auto");
assert_eq!(converted["stream"], true);
}
fn codex_default_body_rules() -> Value {
json!([
{"action":"drop","path":"max_output_tokens"},
@@ -1,13 +1,67 @@
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_openai_responses_request_to_openai_chat_request,
};
use aether_ai_formats::{request_conversion_kind, RequestConversionKind};
use aether_ai_formats::{request_conversion_kind, FormatContext, RequestConversionKind};
use serde_json::{json, Value};
use crate::formats::shared::model_directives::apply_model_directive_overrides_from_request;
fn is_responses_shaped_body_on_chat_endpoint(body_json: &Value) -> bool {
body_json
.as_object()
.is_some_and(|object| !object.contains_key("messages") && object.contains_key("input"))
}
pub fn is_claude_messages_shaped_body_on_openai_chat_endpoint(body_json: &Value) -> bool {
let Some(request) = body_json.as_object() else {
return false;
};
if !request.contains_key("messages") {
return false;
}
request
.get("tools")
.and_then(Value::as_array)
.is_some_and(|tools| tools.iter().any(is_claude_native_tool_definition))
|| request
.get("messages")
.and_then(Value::as_array)
.is_some_and(|messages| messages.iter().any(message_has_claude_tool_block))
}
fn is_claude_native_tool_definition(tool: &Value) -> bool {
tool.as_object().is_some_and(|tool_object| {
tool_object.contains_key("input_schema") && !tool_object.contains_key("function")
})
}
fn message_has_claude_tool_block(message: &Value) -> bool {
message
.as_object()
.and_then(|object| object.get("content"))
.and_then(Value::as_array)
.is_some_and(|parts| parts.iter().any(is_claude_tool_content_block))
}
fn is_claude_tool_content_block(part: &Value) -> bool {
part.as_object()
.and_then(|object| object.get("type"))
.and_then(Value::as_str)
.is_some_and(|block_type| matches!(block_type, "tool_use" | "tool_result"))
}
fn chat_compatible_body_for_openai_chat_endpoint(body_json: &Value) -> Option<Cow<'_, Value>> {
if is_responses_shaped_body_on_chat_endpoint(body_json) {
return normalize_openai_responses_request_to_openai_chat_request(body_json)
.map(Cow::Owned);
}
Some(Cow::Borrowed(body_json))
}
pub fn build_local_openai_chat_request_body(
body_json: &Value,
mapped_model: &str,
@@ -27,7 +81,8 @@ pub fn build_local_openai_chat_request_body_with_model_directives(
upstream_is_stream: bool,
enable_model_directives: bool,
) -> Option<Value> {
let request_body_object = body_json.as_object()?;
let chat_body = chat_compatible_body_for_openai_chat_endpoint(body_json)?;
let request_body_object = chat_body.as_object()?;
let mut provider_request_body = serde_json::Map::from_iter(
request_body_object
.iter()
@@ -94,24 +149,48 @@ pub fn build_cross_format_openai_chat_request_body_with_model_directives(
) -> Option<Value> {
let conversion_kind = request_conversion_kind("openai:chat", provider_api_format)?;
let provider_request_body = match conversion_kind {
RequestConversionKind::ToClaudeStandard => convert_openai_chat_request_to_claude_request(
body_json,
mapped_model,
upstream_is_stream,
)?,
RequestConversionKind::ToGeminiStandard => convert_openai_chat_request_to_gemini_request(
body_json,
mapped_model,
upstream_is_stream,
)?,
RequestConversionKind::ToOpenAiResponses => {
convert_openai_chat_request_to_openai_responses_request(
body_json,
RequestConversionKind::ToClaudeStandard => {
if is_claude_messages_shaped_body_on_openai_chat_endpoint(body_json) {
convert_claude_compatible_chat_endpoint_request(
body_json,
mapped_model,
provider_api_format,
upstream_is_stream,
)?
} else {
let chat_body = chat_compatible_body_for_openai_chat_endpoint(body_json)?;
convert_openai_chat_request_to_claude_request(
chat_body.as_ref(),
mapped_model,
upstream_is_stream,
)?
}
}
RequestConversionKind::ToGeminiStandard => {
let chat_body = chat_compatible_body_for_openai_chat_endpoint(body_json)?;
convert_openai_chat_request_to_gemini_request(
chat_body.as_ref(),
mapped_model,
upstream_is_stream,
false,
)?
}
RequestConversionKind::ToOpenAiResponses => {
if is_responses_shaped_body_on_chat_endpoint(body_json) {
build_local_openai_responses_request_body_with_model_directives(
body_json,
mapped_model,
upstream_is_stream,
enable_model_directives,
)?
} else {
convert_openai_chat_request_to_openai_responses_request(
body_json,
mapped_model,
upstream_is_stream,
false,
)?
}
}
_ => return None,
};
let mut provider_request_body = with_model_directive_overrides(
@@ -134,6 +213,23 @@ pub fn build_cross_format_openai_chat_request_body_with_model_directives(
Some(provider_request_body)
}
fn convert_claude_compatible_chat_endpoint_request(
body_json: &Value,
mapped_model: &str,
provider_api_format: &str,
upstream_is_stream: bool,
) -> Option<Value> {
aether_ai_formats::convert_request(
"claude:messages",
provider_api_format,
body_json,
&FormatContext::default()
.with_mapped_model(mapped_model)
.with_upstream_stream(upstream_is_stream),
)
.ok()
}
pub fn build_local_openai_responses_request_body(
body_json: &Value,
mapped_model: &str,
@@ -342,6 +438,111 @@ mod tests {
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
}
#[test]
fn local_openai_chat_request_body_accepts_responses_shape_from_chat_endpoint() {
let body_json = json!({
"model": "gpt-5",
"stream": true,
"input": [{"role": "user", "content": "hello"}],
"tools": [{
"type": "function",
"name": "Shell",
"parameters": {"type": "object"},
"strict": false
}],
"reasoning": {"effort": "high"}
});
let provider_request_body =
build_local_openai_chat_request_body(&body_json, "gpt-5-upstream", true)
.expect("responses-shaped chat body should build as chat");
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
assert_eq!(provider_request_body["messages"][0]["role"], "user");
assert_eq!(provider_request_body["messages"][0]["content"], "hello");
assert_eq!(
provider_request_body["tools"][0]["function"]["name"],
"Shell"
);
assert_eq!(provider_request_body["reasoning_effort"], "high");
assert_eq!(provider_request_body["stream"], true);
assert_eq!(
provider_request_body["stream_options"]["include_usage"],
true
);
}
#[test]
fn cross_format_openai_chat_request_body_preserves_responses_shape_for_responses_target() {
let body_json = json!({
"model": "gpt-5",
"stream": true,
"input": [{"role": "user", "content": "hello"}],
"include": ["reasoning.encrypted_content"],
"stream_options": {"include_usage": true},
"tools": [{
"type": "function",
"name": "Shell",
"parameters": {"type": "object"},
"strict": false
}, {
"type": "function",
"parameters": {"type": "object"}
}]
});
let provider_request_body =
build_cross_format_openai_chat_request_body_with_model_directives(
&body_json,
"gpt-5-upstream",
"openai:responses",
false,
false,
)
.expect("responses-shaped chat body should build as responses");
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
assert_eq!(provider_request_body["input"][0]["role"], "user");
assert_eq!(provider_request_body["input"][0]["content"], "hello");
assert_eq!(provider_request_body["tools"][0]["name"], "Shell");
assert_eq!(provider_request_body["tools"][0]["strict"], false);
assert_eq!(provider_request_body["tools"][1]["type"], "function");
assert_eq!(
provider_request_body["include"][0],
"reasoning.encrypted_content"
);
assert_eq!(
provider_request_body["stream_options"]["include_usage"],
true
);
assert_eq!(provider_request_body["stream"], false);
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!({
"model": "gpt-5",
"messages": [{"role": "user", "content": "from messages"}],
"input": [{"role": "user", "content": "from input"}]
});
let provider_request_body =
build_cross_format_openai_chat_request_body_with_model_directives(
&body_json,
"gpt-5-upstream",
"openai:responses",
false,
false,
)
.expect("normal chat body should still use messages");
assert_eq!(
provider_request_body["input"][0]["content"][0]["text"],
"from messages"
);
}
#[test]
fn builds_streaming_local_openai_chat_request_body_with_include_usage() {
let body_json = json!({
@@ -572,4 +773,78 @@ mod tests {
);
assert_eq!(provider_request_body["stream_options"]["extra"], "keep-me");
}
#[test]
fn cross_format_openai_chat_request_body_accepts_claude_native_messages_for_claude_target() {
let body_json = json!({
"model": "deepseek-v4-flash",
"messages": [
{"role": "user", "content": "lookup"},
{
"role": "assistant",
"content": [
{"type": "text", "text": "checking"},
{
"type": "tool_use",
"id": "call_1",
"name": "lookup",
"input": {"q": "db"}
}
]
},
{
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": "call_1",
"content": {"rows": 1}
}]
}
],
"tools": [{
"name": "lookup",
"description": "Lookup data",
"input_schema": {"type": "object", "properties": {"q": {"type": "string"}}}
}],
"tool_choice": {"type": "auto"},
"max_tokens": 128,
"stream": true
});
let provider_request_body =
build_cross_format_openai_chat_request_body_with_model_directives(
&body_json,
"claude-sonnet-4-5",
"claude:messages",
true,
false,
)
.expect("claude-native chat endpoint body should build as claude messages");
assert_eq!(provider_request_body["model"], "claude-sonnet-4-5");
assert_eq!(provider_request_body["tools"][0]["name"], "lookup");
assert_eq!(
provider_request_body["tools"][0]["input_schema"]["properties"]["q"]["type"],
"string"
);
assert_eq!(
provider_request_body["messages"][1]["content"][1]["type"],
"tool_use"
);
assert_eq!(
provider_request_body["messages"][2]["content"][0]["type"],
"tool_result"
);
assert_eq!(
serde_json::from_str::<Value>(
provider_request_body["messages"][2]["content"][0]["content"]
.as_str()
.expect("object tool result content should be serialized for Claude")
)
.expect("serialized tool result content should remain JSON"),
json!({"rows": 1})
);
assert_eq!(provider_request_body["tool_choice"]["type"], "auto");
assert_eq!(provider_request_body["stream"], true);
}
}
@@ -108,6 +108,107 @@ pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<Canoni
})
}
pub fn openai_stream_payload_is_terminal_error(payload: &Value) -> bool {
let event_type = payload
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
if payload.get("error").is_some() {
return true;
}
if matches!(
event_type,
"error" | "response.failed" | "response.incomplete"
) {
return true;
}
payload
.get("response")
.and_then(Value::as_object)
.and_then(|response| response.get("status"))
.and_then(Value::as_str)
.is_some_and(|status| matches!(status, "failed" | "incomplete"))
}
pub fn openai_stream_terminal_error_body(payload: &Value) -> Option<Value> {
if !openai_stream_payload_is_terminal_error(payload) {
return None;
}
let event_type = payload
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
let response = payload.get("response").and_then(Value::as_object);
let status = response
.and_then(|response| response.get("status"))
.and_then(Value::as_str);
let raw_error = response
.and_then(|response| response.get("error"))
.or_else(|| payload.get("error"));
let mut error = raw_error
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let message = error
.get("message")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| raw_error.and_then(Value::as_str).map(ToOwned::to_owned))
.or_else(|| {
payload
.get("message")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
.or_else(|| {
response
.and_then(|response| response.get("incomplete_details"))
.and_then(|details| details.get("reason"))
.and_then(Value::as_str)
.map(|reason| format!("Response incomplete: {reason}"))
})
.or_else(|| status.map(|status| format!("Response ended with status {status}")))
.unwrap_or_else(|| "Upstream stream ended with an error".to_string());
error
.entry("message".to_string())
.or_insert_with(|| Value::String(message));
error.entry("type".to_string()).or_insert_with(|| {
if event_type == "response.incomplete" || status == Some("incomplete") {
Value::String("incomplete".to_string())
} else {
Value::String("server_error".to_string())
}
});
if !error.contains_key("code") {
if let Some(reason) = response
.and_then(|response| response.get("incomplete_details"))
.and_then(|details| details.get("reason"))
.and_then(Value::as_str)
{
error.insert("code".to_string(), Value::String(reason.to_string()));
}
}
Some(json!({ "error": Value::Object(error) }))
}
pub fn openai_stream_terminal_error_message(payload: &Value) -> Option<String> {
openai_stream_terminal_error_body(payload)
.and_then(|body| body.get("error").cloned())
.and_then(|error| {
error
.get("message")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
}
pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
let usage = value?.as_object()?;
let input_tokens = usage
@@ -14,7 +14,8 @@ use crate::formats::shared::error_body::{
};
use crate::formats::shared::sse::encode_json_sse;
use crate::formats::shared::stream_core::common::{
decode_json_data_line, CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
decode_json_data_line, openai_stream_terminal_error_body, openai_stream_terminal_error_message,
CanonicalStreamEvent, CanonicalStreamFrame, CanonicalUsage,
};
use crate::formats::shared::AiSurfaceFinalizeError;
@@ -197,6 +198,14 @@ impl StreamingStandardTerminalObserver {
summary.model = Some(model);
}
match event {
CanonicalStreamEvent::UnknownEvent(payload)
if openai_stream_terminal_error_body(&payload).is_some() =>
{
summary.unknown_event_count = summary.unknown_event_count.saturating_add(1);
summary.observed_finish = true;
summary.finish_reason = Some("error".to_string());
summary.parser_error = openai_stream_terminal_error_message(&payload);
}
CanonicalStreamEvent::UnknownEvent(_) => {
summary.unknown_event_count = summary.unknown_event_count.saturating_add(1);
}
@@ -410,7 +419,8 @@ fn parse_provider_error(
}
fn parse_openai_error(payload: &Value) -> Option<(String, Option<String>, LocalCoreSyncErrorKind)> {
let error = payload.get("error")?.as_object()?;
let error_body = openai_stream_terminal_error_body(payload)?;
let error = error_body.get("error")?.as_object()?;
let message = error.get("message").and_then(Value::as_str)?.to_string();
let code = error
.get("code")
@@ -973,6 +983,41 @@ mod tests {
assert!(!summary.observed_finish);
}
#[test]
fn terminal_observer_marks_openai_responses_failed_event_as_terminal_error() {
let mut report_context = report_context("openai:chat", "openai:responses");
report_context["provider_stream_event_api_format"] = json!("openai:responses");
let mut observer = StreamingStandardTerminalObserver::default();
observer
.push_line(
&report_context,
data_line(json!({
"type": "response.failed",
"response": {
"id": "resp_failed_123",
"model": "gpt-5.4",
"status": "failed",
"error": {
"message": "policy failure",
"type": "invalid_request_error",
"code": "cyber_policy"
}
}
})),
)
.expect("failed event should be observed");
let summary = observer
.latest_summary()
.cloned()
.expect("summary should exist");
assert!(summary.observed_finish);
assert_eq!(summary.finish_reason.as_deref(), Some("error"));
assert_eq!(summary.parser_error.as_deref(), Some("policy failure"));
assert_eq!(summary.unknown_event_count, 1);
}
#[test]
fn terminal_observer_tracks_openai_image_stream_usage() {
let mut report_context = report_context("openai:image", "openai:chat");