mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 18:59:50 +08:00
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:
@@ -175,6 +175,7 @@ pub use crate::formats::{
|
||||
build_local_openai_chat_request_body_with_model_directives,
|
||||
build_local_openai_responses_request_body,
|
||||
build_local_openai_responses_request_body_with_model_directives,
|
||||
is_claude_messages_shaped_body_on_openai_chat_endpoint,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -211,10 +212,10 @@ pub use crate::provider_compat::kiro_stream::{
|
||||
KiroToClaudeCliStreamState, KIRO_MAX_THINKING_BUFFER,
|
||||
};
|
||||
pub use crate::provider_compat::private_envelope::{
|
||||
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
|
||||
normalize_provider_private_response_value, provider_private_response_allows_sync_finalize,
|
||||
stream_body_contains_error_event, transform_provider_private_stream_line,
|
||||
ProviderPrivateStreamNormalizer,
|
||||
extract_provider_private_stream_error_body, maybe_build_provider_private_stream_normalizer,
|
||||
normalize_provider_private_report_context, normalize_provider_private_response_value,
|
||||
provider_private_response_allows_sync_finalize, stream_body_contains_error_event,
|
||||
transform_provider_private_stream_line, ProviderPrivateStreamNormalizer,
|
||||
};
|
||||
pub use crate::provider_compat::surfaces::{
|
||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -29,7 +29,7 @@ pub use formats::shared::model_directives::{
|
||||
};
|
||||
pub use formats::shared::request::{
|
||||
endpoint_config_forces_upstream_stream_policy, enforce_request_body_stream_field,
|
||||
resolve_upstream_is_stream_from_endpoint_config,
|
||||
resolve_upstream_is_stream_from_endpoint_config, UPSTREAM_IS_STREAM_KEY,
|
||||
};
|
||||
pub use protocol::canonical::{
|
||||
canonical_request_unknown_block_count, canonical_response_unknown_block_count,
|
||||
|
||||
@@ -1556,6 +1556,39 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
|
||||
extensions: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
"web_search_call" => {
|
||||
let id = item_object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| {
|
||||
let generated =
|
||||
format!("call_auto_{next_generated_tool_call_index}");
|
||||
next_generated_tool_call_index += 1;
|
||||
generated
|
||||
});
|
||||
let query = item_object
|
||||
.get("action")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|action| action.get("query"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
messages.push(CanonicalMessage {
|
||||
role: CanonicalRole::Assistant,
|
||||
content: vec![CanonicalContentBlock::ToolUse {
|
||||
id,
|
||||
name: "web_search".to_string(),
|
||||
input: json!({ "query": query }),
|
||||
extensions: openai_responses_extensions(
|
||||
item_object,
|
||||
&["type", "id", "status", "action"],
|
||||
),
|
||||
}],
|
||||
extensions: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
"function_call_output" => {
|
||||
let id = item_object
|
||||
.get("call_id")
|
||||
@@ -1729,6 +1762,30 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
|
||||
),
|
||||
});
|
||||
}
|
||||
"web_search_call" => {
|
||||
let id = item_object
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("call_auto_{index}"));
|
||||
let query = item_object
|
||||
.get("action")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|action| action.get("query"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
blocks.push(CanonicalContentBlock::ToolUse {
|
||||
id,
|
||||
name: "web_search".to_string(),
|
||||
input: json!({ "query": query }),
|
||||
extensions: openai_responses_extensions(
|
||||
item_object,
|
||||
&["type", "id", "status", "action"],
|
||||
),
|
||||
});
|
||||
}
|
||||
"function_call_output" => {
|
||||
let id = item_object
|
||||
.get("call_id")
|
||||
@@ -3763,9 +3820,11 @@ pub(crate) fn canonical_block_to_claude(
|
||||
);
|
||||
out.insert(
|
||||
"content".to_string(),
|
||||
output
|
||||
.clone()
|
||||
.unwrap_or_else(|| Value::String(content_text.clone().unwrap_or_default())),
|
||||
canonical_tool_result_content_to_claude(
|
||||
output.as_ref(),
|
||||
content_text.as_deref(),
|
||||
role,
|
||||
),
|
||||
);
|
||||
out.insert("is_error".to_string(), Value::Bool(*is_error));
|
||||
out.extend(namespace_extension_object(extensions, "claude", &out));
|
||||
@@ -3775,6 +3834,41 @@ pub(crate) fn canonical_block_to_claude(
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_tool_result_content_to_claude(
|
||||
output: Option<&Value>,
|
||||
content_text: Option<&str>,
|
||||
role: &CanonicalRole,
|
||||
) -> Value {
|
||||
if matches!(role, CanonicalRole::Assistant) {
|
||||
return output
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Value::String(content_text.unwrap_or_default().to_string()));
|
||||
}
|
||||
|
||||
match output {
|
||||
Some(Value::String(text)) => Value::String(text.clone()),
|
||||
Some(Value::Array(parts)) if claude_tool_result_content_blocks_are_wire_safe(parts) => {
|
||||
Value::Array(parts.clone())
|
||||
}
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_tool_result_content_blocks_are_wire_safe(parts: &[Value]) -> bool {
|
||||
!parts.is_empty()
|
||||
&& parts.iter().all(|part| {
|
||||
part.as_object()
|
||||
.and_then(|object| object.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|block_type| {
|
||||
matches!(block_type, "text" | "image" | "document" | "file")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn claude_source_value(
|
||||
media_type: Option<&str>,
|
||||
data: Option<&str>,
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::provider_compat::kiro_stream::KiroToClaudeCliStreamState;
|
||||
use super::surfaces::{
|
||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_descriptor_for_envelope,
|
||||
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME, WINDSURF_ENVELOPE_NAME,
|
||||
};
|
||||
|
||||
pub fn provider_private_response_allows_sync_finalize(report_context: &Value) -> bool {
|
||||
@@ -92,6 +92,7 @@ pub fn normalize_provider_private_response_value(
|
||||
data
|
||||
}
|
||||
}
|
||||
Some(WINDSURF_ENVELOPE_NAME) => normalize_windsurf_sync_response_value(data)?,
|
||||
_ => return None,
|
||||
};
|
||||
postprocess_private_response_value(&mut unwrapped, report_context);
|
||||
@@ -101,14 +102,28 @@ pub fn normalize_provider_private_response_value(
|
||||
pub fn transform_provider_private_stream_line(
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
) -> Result<Vec<u8>, serde_json::Error> {
|
||||
transform_provider_private_stream_line_with_event_state(report_context, line, &mut None)
|
||||
}
|
||||
|
||||
fn transform_provider_private_stream_line_with_event_state(
|
||||
report_context: &Value,
|
||||
line: Vec<u8>,
|
||||
current_event_type: &mut Option<String>,
|
||||
) -> Result<Vec<u8>, serde_json::Error> {
|
||||
let Ok(text) = std::str::from_utf8(&line) else {
|
||||
return Ok(line);
|
||||
};
|
||||
let trimmed = text.trim_matches('\r').trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with(':') || trimmed.starts_with("event:") {
|
||||
if trimmed.is_empty() || trimmed.starts_with(':') {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if let Some(event_name) = trimmed.strip_prefix("event:") {
|
||||
let event_name = event_name.trim().to_string();
|
||||
let is_error = event_name.eq_ignore_ascii_case("error");
|
||||
*current_event_type = (!event_name.is_empty()).then_some(event_name);
|
||||
return if is_error { Ok(line) } else { Ok(Vec::new()) };
|
||||
}
|
||||
let Some(data_line) = trimmed.strip_prefix("data:") else {
|
||||
return Ok(line);
|
||||
};
|
||||
@@ -121,6 +136,13 @@ pub fn transform_provider_private_stream_line(
|
||||
Ok(value) => value,
|
||||
Err(_) => return Ok(line),
|
||||
};
|
||||
let event_is_error = current_event_type
|
||||
.as_deref()
|
||||
.is_some_and(|event| event.eq_ignore_ascii_case("error"));
|
||||
*current_event_type = None;
|
||||
if event_is_error {
|
||||
return Ok(line);
|
||||
}
|
||||
|
||||
let envelope_name = report_context
|
||||
.get("envelope_name")
|
||||
@@ -133,6 +155,9 @@ pub fn transform_provider_private_stream_line(
|
||||
if !provider_adaptation_should_unwrap_stream_envelope(envelope_name, provider_api_format) {
|
||||
return Ok(line);
|
||||
}
|
||||
if envelope_name == WINDSURF_ENVELOPE_NAME && looks_like_windsurf_error(&body) {
|
||||
return Ok(line);
|
||||
}
|
||||
let unwrapped = match envelope_name {
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME => body.get("response").cloned().unwrap_or(body),
|
||||
ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME => {
|
||||
@@ -147,6 +172,7 @@ pub fn transform_provider_private_stream_line(
|
||||
inject_antigravity_stream_tool_ids(&mut response);
|
||||
response
|
||||
}
|
||||
WINDSURF_ENVELOPE_NAME => normalize_windsurf_stream_event_value(&body).unwrap_or(body),
|
||||
_ => body,
|
||||
};
|
||||
|
||||
@@ -156,6 +182,96 @@ pub fn transform_provider_private_stream_line(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
const CONNECT_FRAME_HEADER_BYTES: usize = 5;
|
||||
const MAX_CONNECT_JSON_FRAME_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
fn report_context_is_windsurf_envelope(report_context: &Value) -> bool {
|
||||
report_context
|
||||
.get("envelope_name")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(WINDSURF_ENVELOPE_NAME))
|
||||
}
|
||||
|
||||
fn buffer_looks_like_connect_frame(buffer: &[u8]) -> bool {
|
||||
let Some(flags) = buffer.first().copied() else {
|
||||
return false;
|
||||
};
|
||||
if flags & !0x03 != 0 {
|
||||
return false;
|
||||
}
|
||||
if buffer.len() < CONNECT_FRAME_HEADER_BYTES {
|
||||
return true;
|
||||
}
|
||||
let len = u32::from_be_bytes([buffer[1], buffer[2], buffer[3], buffer[4]]) as usize;
|
||||
len <= MAX_CONNECT_JSON_FRAME_BYTES
|
||||
}
|
||||
|
||||
fn drain_windsurf_connect_json_frames(
|
||||
buffer: &mut Vec<u8>,
|
||||
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
let mut output = Vec::new();
|
||||
while buffer.len() >= CONNECT_FRAME_HEADER_BYTES {
|
||||
let flags = buffer[0];
|
||||
if flags & !0x03 != 0 {
|
||||
return Err(AiSurfaceFinalizeError::new(format!(
|
||||
"invalid Connect frame flags: {flags}"
|
||||
)));
|
||||
}
|
||||
let len = u32::from_be_bytes([buffer[1], buffer[2], buffer[3], buffer[4]]) as usize;
|
||||
if len > MAX_CONNECT_JSON_FRAME_BYTES {
|
||||
return Err(AiSurfaceFinalizeError::new(format!(
|
||||
"Connect frame size {len} exceeds {MAX_CONNECT_JSON_FRAME_BYTES}"
|
||||
)));
|
||||
}
|
||||
if buffer.len() < CONNECT_FRAME_HEADER_BYTES + len {
|
||||
break;
|
||||
}
|
||||
let payload = buffer[CONNECT_FRAME_HEADER_BYTES..CONNECT_FRAME_HEADER_BYTES + len].to_vec();
|
||||
buffer.drain(..CONNECT_FRAME_HEADER_BYTES + len);
|
||||
|
||||
if flags & 0x01 != 0 {
|
||||
return Err(AiSurfaceFinalizeError::new(
|
||||
"compressed Connect JSON frames are not supported for Windsurf chat",
|
||||
));
|
||||
}
|
||||
if payload.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let body: Value = serde_json::from_slice(&payload)?;
|
||||
if flags & 0x02 != 0 {
|
||||
if let Some(error) = body.get("error") {
|
||||
output.extend_from_slice(b"event: error\n");
|
||||
output.extend_from_slice(b"data: ");
|
||||
output.extend(serde_json::to_vec(error)?);
|
||||
output.extend_from_slice(b"\n\n");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if looks_like_windsurf_error(&body) {
|
||||
output.extend_from_slice(b"event: error\n");
|
||||
output.extend_from_slice(b"data: ");
|
||||
output.extend(serde_json::to_vec(&body)?);
|
||||
output.extend_from_slice(b"\n\n");
|
||||
continue;
|
||||
}
|
||||
let unwrapped = normalize_windsurf_stream_event_value(&body).unwrap_or(body);
|
||||
let mut line = b"data: ".to_vec();
|
||||
line.extend(serde_json::to_vec(&unwrapped)?);
|
||||
line.extend_from_slice(b"\n\n");
|
||||
output.extend(line);
|
||||
}
|
||||
|
||||
if !buffer.is_empty()
|
||||
&& buffer.len() < CONNECT_FRAME_HEADER_BYTES
|
||||
&& !buffer_looks_like_connect_frame(buffer)
|
||||
{
|
||||
return Err(AiSurfaceFinalizeError::new(
|
||||
"invalid partial Connect JSON frame",
|
||||
));
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
enum ProviderPrivateStreamNormalizeMode {
|
||||
EnvelopeUnwrap,
|
||||
KiroToClaudeCli(Box<KiroToClaudeCliStreamState>),
|
||||
@@ -164,6 +280,7 @@ enum ProviderPrivateStreamNormalizeMode {
|
||||
pub struct ProviderPrivateStreamNormalizer<'a> {
|
||||
report_context: &'a Value,
|
||||
buffered: Vec<u8>,
|
||||
current_event_type: Option<String>,
|
||||
mode: ProviderPrivateStreamNormalizeMode,
|
||||
}
|
||||
|
||||
@@ -203,10 +320,24 @@ pub fn maybe_build_provider_private_stream_normalizer<'a>(
|
||||
Some(ProviderPrivateStreamNormalizer {
|
||||
report_context,
|
||||
buffered: Vec::new(),
|
||||
current_event_type: None,
|
||||
mode,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn extract_provider_private_stream_error_body(
|
||||
report_context: Option<&Value>,
|
||||
body: &[u8],
|
||||
) -> Option<Value> {
|
||||
if report_context.is_none_or(report_context_is_windsurf_envelope) {
|
||||
if let Some(error_body) = extract_windsurf_connect_json_error_body(body) {
|
||||
return Some(error_body);
|
||||
}
|
||||
}
|
||||
|
||||
extract_stream_error_event_body(body)
|
||||
}
|
||||
|
||||
impl ProviderPrivateStreamNormalizer<'_> {
|
||||
pub fn push_chunk(&mut self, chunk: &[u8]) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
|
||||
match &mut self.mode {
|
||||
@@ -215,12 +346,21 @@ impl ProviderPrivateStreamNormalizer<'_> {
|
||||
}
|
||||
ProviderPrivateStreamNormalizeMode::EnvelopeUnwrap => {
|
||||
self.buffered.extend_from_slice(chunk);
|
||||
if report_context_is_windsurf_envelope(self.report_context)
|
||||
&& buffer_looks_like_connect_frame(&self.buffered)
|
||||
{
|
||||
return drain_windsurf_connect_json_frames(&mut self.buffered);
|
||||
}
|
||||
let mut output = Vec::new();
|
||||
while let Some(line_end) = self.buffered.iter().position(|byte| *byte == b'\n') {
|
||||
let line = self.buffered.drain(..=line_end).collect::<Vec<_>>();
|
||||
output.extend(
|
||||
transform_provider_private_stream_line(self.report_context, line)
|
||||
.map_err(AiSurfaceFinalizeError::from)?,
|
||||
transform_provider_private_stream_line_with_event_state(
|
||||
self.report_context,
|
||||
line,
|
||||
&mut self.current_event_type,
|
||||
)
|
||||
.map_err(AiSurfaceFinalizeError::from)?,
|
||||
);
|
||||
}
|
||||
Ok(output)
|
||||
@@ -237,18 +377,235 @@ impl ProviderPrivateStreamNormalizer<'_> {
|
||||
if self.buffered.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if report_context_is_windsurf_envelope(self.report_context)
|
||||
&& buffer_looks_like_connect_frame(&self.buffered)
|
||||
{
|
||||
return drain_windsurf_connect_json_frames(&mut self.buffered);
|
||||
}
|
||||
let line = std::mem::take(&mut self.buffered);
|
||||
transform_provider_private_stream_line(self.report_context, line)
|
||||
.map_err(AiSurfaceFinalizeError::from)
|
||||
transform_provider_private_stream_line_with_event_state(
|
||||
self.report_context,
|
||||
line,
|
||||
&mut self.current_event_type,
|
||||
)
|
||||
.map_err(AiSurfaceFinalizeError::from)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stream_body_contains_error_event(body: &[u8]) -> bool {
|
||||
let Ok(text) = std::str::from_utf8(body) else {
|
||||
fn normalize_windsurf_sync_response_value(data: Value) -> Option<Value> {
|
||||
if looks_like_openai_chat_response(&data) {
|
||||
return Some(data);
|
||||
}
|
||||
if looks_like_windsurf_error(&data) {
|
||||
return None;
|
||||
}
|
||||
if let Some(response) = data
|
||||
.get("response")
|
||||
.or_else(|| data.get("message"))
|
||||
.or_else(|| data.get("chatMessage"))
|
||||
.cloned()
|
||||
{
|
||||
if looks_like_openai_chat_response(&response) {
|
||||
return Some(response);
|
||||
}
|
||||
if let Some(text) = extract_windsurf_text(&response) {
|
||||
return Some(build_openai_chat_response_from_text(&data, text));
|
||||
}
|
||||
}
|
||||
extract_windsurf_text(&data).map(|text| build_openai_chat_response_from_text(&data, text))
|
||||
}
|
||||
|
||||
fn normalize_windsurf_stream_event_value(data: &Value) -> Option<Value> {
|
||||
if looks_like_openai_chat_stream_event(data) {
|
||||
return Some(data.clone());
|
||||
}
|
||||
if looks_like_windsurf_error(data) {
|
||||
return None;
|
||||
}
|
||||
let response = data
|
||||
.get("response")
|
||||
.or_else(|| data.get("message"))
|
||||
.or_else(|| data.get("chatMessage"))
|
||||
.unwrap_or(data);
|
||||
if looks_like_openai_chat_stream_event(response) {
|
||||
return Some(response.clone());
|
||||
}
|
||||
extract_windsurf_text(response).map(|text| {
|
||||
serde_json::json!({
|
||||
"id": windsurf_response_id(data),
|
||||
"object": "chat.completion.chunk",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {"content": text},
|
||||
"finish_reason": null
|
||||
}]
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn looks_like_openai_chat_response(value: &Value) -> bool {
|
||||
value
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|choices| !choices.is_empty())
|
||||
}
|
||||
|
||||
fn looks_like_openai_chat_stream_event(value: &Value) -> bool {
|
||||
value
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|choices| choices.first())
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|choice| choice.contains_key("delta"))
|
||||
}
|
||||
|
||||
fn looks_like_windsurf_error(value: &Value) -> bool {
|
||||
let Some(object) = value.as_object() else {
|
||||
return false;
|
||||
};
|
||||
if object.contains_key("error") {
|
||||
return true;
|
||||
}
|
||||
if object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("error"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if object.contains_key("code") || object.contains_key("status") {
|
||||
return object
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
}
|
||||
object
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
&& !object.contains_key("response")
|
||||
&& !object.contains_key("chatMessage")
|
||||
&& !object.contains_key("choices")
|
||||
&& !object.contains_key("text")
|
||||
&& !object.contains_key("content")
|
||||
&& !object.contains_key("assistantMessage")
|
||||
&& !object.contains_key("assistant_message")
|
||||
}
|
||||
|
||||
fn extract_windsurf_text(value: &Value) -> Option<String> {
|
||||
if let Some(text) = value
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(text.to_string());
|
||||
}
|
||||
let object = value.as_object()?;
|
||||
for key in [
|
||||
"text",
|
||||
"content",
|
||||
"message",
|
||||
"answer",
|
||||
"completion",
|
||||
"assistantMessage",
|
||||
"assistant_message",
|
||||
] {
|
||||
if let Some(text) = object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Some(text.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn windsurf_response_id(value: &Value) -> String {
|
||||
value
|
||||
.get("id")
|
||||
.or_else(|| value.get("responseId"))
|
||||
.or_else(|| value.get("messageId"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("windsurf-cascade")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_openai_chat_response_from_text(source: &Value, text: String) -> Value {
|
||||
serde_json::json!({
|
||||
"id": windsurf_response_id(source),
|
||||
"object": "chat.completion",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": text},
|
||||
"finish_reason": "stop"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
pub fn stream_body_contains_error_event(body: &[u8]) -> bool {
|
||||
if extract_windsurf_connect_json_error_body(body).is_some() {
|
||||
return true;
|
||||
}
|
||||
extract_stream_error_event_body(body).is_some()
|
||||
}
|
||||
|
||||
fn extract_windsurf_connect_json_error_body(body: &[u8]) -> Option<Value> {
|
||||
if !buffer_looks_like_connect_frame(body) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut offset = 0usize;
|
||||
while body.len().saturating_sub(offset) >= CONNECT_FRAME_HEADER_BYTES {
|
||||
let flags = body[offset];
|
||||
if flags & !0x03 != 0 {
|
||||
return None;
|
||||
}
|
||||
let len = u32::from_be_bytes([
|
||||
body[offset + 1],
|
||||
body[offset + 2],
|
||||
body[offset + 3],
|
||||
body[offset + 4],
|
||||
]) as usize;
|
||||
if len > MAX_CONNECT_JSON_FRAME_BYTES {
|
||||
return None;
|
||||
}
|
||||
let frame_end = offset + CONNECT_FRAME_HEADER_BYTES + len;
|
||||
if body.len() < frame_end {
|
||||
return None;
|
||||
}
|
||||
if flags & 0x01 != 0 {
|
||||
return None;
|
||||
}
|
||||
let payload = &body[offset + CONNECT_FRAME_HEADER_BYTES..frame_end];
|
||||
offset = frame_end;
|
||||
if payload.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed: Value = serde_json::from_slice(payload).ok()?;
|
||||
if flags & 0x02 != 0 {
|
||||
if let Some(error) = parsed.get("error").filter(|value| !value.is_null()) {
|
||||
return Some(normalize_provider_private_error_body(error.clone()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if looks_like_windsurf_error(&parsed) {
|
||||
return Some(normalize_provider_private_error_body(parsed));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_stream_error_event_body(body: &[u8]) -> Option<Value> {
|
||||
let Ok(text) = std::str::from_utf8(body) else {
|
||||
return None;
|
||||
};
|
||||
let mut current_event_type: Option<String> = None;
|
||||
for raw_line in text.lines() {
|
||||
let line = raw_line.trim_matches('\r').trim();
|
||||
@@ -282,11 +639,35 @@ pub fn stream_body_contains_error_event(body: &[u8]) -> bool {
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case("error"))
|
||||
{
|
||||
return true;
|
||||
return Some(normalize_provider_private_error_body(event));
|
||||
}
|
||||
current_event_type = None;
|
||||
}
|
||||
false
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_provider_private_error_body(error: Value) -> Value {
|
||||
let mut error = if error.get("error").is_some_and(|value| !value.is_null()) {
|
||||
error
|
||||
} else {
|
||||
serde_json::json!({ "error": error })
|
||||
};
|
||||
|
||||
if let Some(error_object) = error.get_mut("error").and_then(Value::as_object_mut) {
|
||||
if !error_object.contains_key("type") {
|
||||
if let Some(kind) = error_object
|
||||
.get("code")
|
||||
.or_else(|| error_object.get("status"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
error_object.insert("type".to_string(), Value::String(kind.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error
|
||||
}
|
||||
|
||||
fn clear_private_envelope_context(report_context: &Value) -> Value {
|
||||
@@ -427,9 +808,9 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
maybe_build_provider_private_stream_normalizer, normalize_provider_private_report_context,
|
||||
normalize_provider_private_response_value, stream_body_contains_error_event,
|
||||
transform_provider_private_stream_line,
|
||||
extract_provider_private_stream_error_body, maybe_build_provider_private_stream_normalizer,
|
||||
normalize_provider_private_report_context, normalize_provider_private_response_value,
|
||||
stream_body_contains_error_event, transform_provider_private_stream_line,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -497,6 +878,104 @@ mod tests {
|
||||
assert!(output_text.contains("\"id\":\"call_get_weather_0\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_windsurf_sync_text_response_to_openai_chat() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"provider_api_format": "openai:chat",
|
||||
});
|
||||
let normalized = normalize_provider_private_response_value(
|
||||
json!({
|
||||
"responseId": "ws-1",
|
||||
"response": {"text": "hello from cascade"}
|
||||
}),
|
||||
&report_context,
|
||||
)
|
||||
.expect("windsurf response should normalize");
|
||||
|
||||
assert_eq!(normalized["id"], json!("ws-1"));
|
||||
assert_eq!(
|
||||
normalized["choices"][0]["message"]["content"],
|
||||
json!("hello from cascade")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwraps_windsurf_stream_text_event() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"provider_api_format": "openai:chat",
|
||||
});
|
||||
let output = transform_provider_private_stream_line(
|
||||
&report_context,
|
||||
br#"data: {"responseId":"ws-2","response":{"text":"chunk"}}"#.to_vec(),
|
||||
)
|
||||
.expect("windsurf stream line should transform");
|
||||
let text = String::from_utf8(output).expect("utf8");
|
||||
|
||||
assert!(text.contains(r#""object":"chat.completion.chunk""#));
|
||||
assert!(text.contains(r#""content":"chunk""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwraps_windsurf_connect_json_stream_frames() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"provider_api_format": "openai:chat",
|
||||
});
|
||||
let mut normalizer = maybe_build_provider_private_stream_normalizer(Some(&report_context))
|
||||
.expect("normalizer should exist");
|
||||
let mut framed = connect_json_frame(
|
||||
0,
|
||||
br#"{"responseId":"ws-3","response":{"text":"frame chunk"}}"#,
|
||||
);
|
||||
framed.extend(connect_json_frame(2, b"{}"));
|
||||
|
||||
let mut output = normalizer
|
||||
.push_chunk(&framed)
|
||||
.expect("connect frame should normalize");
|
||||
output.extend(normalizer.finish().expect("finish should succeed"));
|
||||
let text = String::from_utf8(output).expect("utf8");
|
||||
|
||||
assert!(text.contains(r#""object":"chat.completion.chunk""#));
|
||||
assert!(text.contains(r#""content":"frame chunk""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_windsurf_connect_json_trailer_error_frame() {
|
||||
let framed = connect_json_frame(
|
||||
2,
|
||||
br#"{"error":{"code":"resource_exhausted","message":"quota exhausted"}}"#,
|
||||
);
|
||||
|
||||
assert!(stream_body_contains_error_event(&framed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_connect_json_trailer_error_without_report_context() {
|
||||
let framed = connect_json_frame(
|
||||
2,
|
||||
br#"{"error":{"code":"resource_exhausted","message":"quota exhausted"}}"#,
|
||||
);
|
||||
|
||||
let body = extract_provider_private_stream_error_body(None, &framed)
|
||||
.expect("Connect trailer error should decode without report context");
|
||||
|
||||
assert_eq!(body["error"]["code"], json!("resource_exhausted"));
|
||||
assert_eq!(body["error"]["message"], json!("quota exhausted"));
|
||||
}
|
||||
|
||||
fn connect_json_frame(flags: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(5 + payload.len());
|
||||
out.push(flags);
|
||||
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_stream_normalizer_unwraps_antigravity_stream() {
|
||||
let report_context = json!({
|
||||
@@ -526,4 +1005,39 @@ data: {"message":"bad"}
|
||||
"#;
|
||||
assert!(stream_body_contains_error_event(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_sync_error_message_is_not_normalized_as_success() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"provider_api_format": "openai:chat",
|
||||
});
|
||||
|
||||
let normalized = normalize_provider_private_response_value(
|
||||
json!({"message": "rate limited"}),
|
||||
&report_context,
|
||||
);
|
||||
|
||||
assert!(normalized.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windsurf_stream_error_event_is_preserved() {
|
||||
let report_context = json!({
|
||||
"has_envelope": true,
|
||||
"envelope_name": "windsurf:GetChatMessage",
|
||||
"provider_api_format": "openai:chat",
|
||||
});
|
||||
let mut normalizer = maybe_build_provider_private_stream_normalizer(Some(&report_context))
|
||||
.expect("normalizer should exist");
|
||||
let output = normalizer
|
||||
.push_chunk(b"event: error\ndata: {\"message\":\"rate limited\"}\n\n")
|
||||
.expect("normalizer should preserve error event");
|
||||
let output_text = String::from_utf8(output).expect("utf8");
|
||||
|
||||
assert!(output_text.contains("event: error"));
|
||||
assert!(output_text.contains("\"message\":\"rate limited\""));
|
||||
assert!(!output_text.contains("chat.completion.chunk"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
pub const ANTIGRAVITY_PROVIDER_TYPE: &str = "antigravity";
|
||||
pub const KIRO_PROVIDER_TYPE: &str = "kiro";
|
||||
pub const WINDSURF_PROVIDER_TYPE: &str = "windsurf";
|
||||
pub const KIRO_ENVELOPE_NAME: &str = "kiro:generateAssistantResponse";
|
||||
pub const ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME: &str = "antigravity:v1internal";
|
||||
pub const GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME: &str = "gemini_cli:v1internal";
|
||||
pub const WINDSURF_ENVELOPE_NAME: &str = "windsurf:GetChatMessage";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProviderAdaptationSurface {
|
||||
@@ -10,6 +12,7 @@ pub enum ProviderAdaptationSurface {
|
||||
AntigravityGeminiCli,
|
||||
GeminiCliV1Internal,
|
||||
KiroClaudeCli,
|
||||
WindsurfCascade,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
@@ -70,6 +73,17 @@ const PROVIDER_ADAPTATION_SURFACES: &[ProviderAdaptationDescriptor] = &[
|
||||
requires_eventstream_accept: true,
|
||||
unwraps_response_envelope: false,
|
||||
},
|
||||
ProviderAdaptationDescriptor {
|
||||
surface: ProviderAdaptationSurface::WindsurfCascade,
|
||||
provider_type: Some(WINDSURF_PROVIDER_TYPE),
|
||||
envelope_name: WINDSURF_ENVELOPE_NAME,
|
||||
anchor_api_format: "openai:chat",
|
||||
supports_request_bridge: true,
|
||||
supports_sync_finalize_bridge: true,
|
||||
supports_stream_bridge: true,
|
||||
requires_eventstream_accept: false,
|
||||
unwraps_response_envelope: true,
|
||||
},
|
||||
];
|
||||
|
||||
pub fn provider_adaptation_descriptor_for_envelope(
|
||||
@@ -141,7 +155,7 @@ mod tests {
|
||||
provider_adaptation_allows_sync_finalize_envelope, provider_adaptation_anchor_api_format,
|
||||
provider_adaptation_requires_eventstream_accept,
|
||||
provider_adaptation_should_unwrap_stream_envelope, ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME,
|
||||
GEMINI_CLI_V1INTERNAL_ENVELOPE_NAME, KIRO_ENVELOPE_NAME, WINDSURF_ENVELOPE_NAME,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -164,6 +178,10 @@ mod tests {
|
||||
provider_adaptation_anchor_api_format(KIRO_ENVELOPE_NAME, "claude:messages"),
|
||||
Some("claude:messages")
|
||||
);
|
||||
assert_eq!(
|
||||
provider_adaptation_anchor_api_format(WINDSURF_ENVELOPE_NAME, "openai:chat"),
|
||||
Some("openai:chat")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -184,5 +202,9 @@ mod tests {
|
||||
Some(ANTIGRAVITY_V1INTERNAL_ENVELOPE_NAME),
|
||||
"gemini:generate_content"
|
||||
));
|
||||
assert!(provider_adaptation_should_unwrap_stream_envelope(
|
||||
WINDSURF_ENVELOPE_NAME,
|
||||
"openai:chat"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user