fix(gateway): sanitize Claude thinking and handle missing stream finish

This commit is contained in:
zhefox
2026-05-21 12:30:42 +08:00
parent 923515ab28
commit e59e6c3797
5 changed files with 612 additions and 53 deletions

View File

@@ -61,6 +61,134 @@ fn is_grok_text_provider_api_format(provider_api_format: &str) -> bool {
) )
} }
fn provider_preserves_claude_thinking_signatures(provider_type: &str, base_url: &str) -> bool {
let provider_type = provider_type.trim().to_ascii_lowercase();
let base_url = base_url.trim().to_ascii_lowercase();
let is_bedrock_runtime_url = base_url.contains("bedrock-runtime")
&& (base_url.contains("amazonaws.com")
|| base_url.contains("amazonaws.com.cn")
|| base_url.contains("api.aws"));
matches!(
provider_type.as_str(),
"anthropic" | "claude_code" | "bedrock" | "aws_bedrock" | "amazon_bedrock"
) || base_url.contains("api.anthropic.com")
|| is_bedrock_runtime_url
}
fn sanitize_claude_thinking_block(block: Value) -> (Option<Value>, bool) {
let Some(object) = block.as_object() else {
return (Some(block), false);
};
let block_type = object
.get("type")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
match block_type {
"thinking" => {
let thinking_text = object
.get("thinking")
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default();
if thinking_text.is_empty() {
(None, true)
} else {
(
Some(serde_json::json!({
"type": "text",
"text": thinking_text,
})),
true,
)
}
}
"redacted_thinking" => (None, true),
_ => (Some(block), false),
}
}
fn sanitize_claude_message_content_for_non_native_thinking(content: &mut Value) -> bool {
const OMITTED_THINKING_TEXT: &str = "Previous thinking omitted.";
if content.is_object() {
let original = std::mem::take(content);
let (sanitized, changed) = sanitize_claude_thinking_block(original);
if changed {
*content = sanitized.unwrap_or_else(|| {
serde_json::json!({
"type": "text",
"text": OMITTED_THINKING_TEXT,
})
});
}
return changed;
}
let Some(blocks) = content.as_array_mut() else {
return false;
};
let original_blocks = std::mem::take(blocks);
let mut changed = false;
let mut sanitized_blocks = Vec::with_capacity(original_blocks.len());
for block in original_blocks {
let (sanitized, block_changed) = sanitize_claude_thinking_block(block);
changed |= block_changed;
if let Some(sanitized) = sanitized {
sanitized_blocks.push(sanitized);
}
}
if changed && sanitized_blocks.is_empty() {
sanitized_blocks.push(serde_json::json!({
"type": "text",
"text": OMITTED_THINKING_TEXT,
}));
}
*blocks = sanitized_blocks;
changed
}
fn sanitize_claude_request_thinking_signatures_for_non_native(body_json: &mut Value) -> bool {
body_json
.get_mut("messages")
.and_then(Value::as_array_mut)
.map(|messages| {
messages.iter_mut().fold(false, |changed, message| {
let is_assistant = message
.get("role")
.and_then(Value::as_str)
.is_some_and(|role| role.trim().eq_ignore_ascii_case("assistant"));
if !is_assistant {
return changed;
}
let content_changed = message
.get_mut("content")
.is_some_and(sanitize_claude_message_content_for_non_native_thinking);
changed || content_changed
})
})
.unwrap_or(false)
}
fn apply_non_native_claude_thinking_signature_compat(
provider_request_body: &mut Value,
provider_api_format: &str,
transport: &GatewayProviderTransportSnapshot,
) {
if crate::ai_serving::normalize_api_format_alias(provider_api_format) != "claude:messages" {
return;
}
if provider_preserves_claude_thinking_signatures(
transport.provider.provider_type.as_str(),
transport.endpoint.base_url.as_str(),
) {
return;
}
let _ = sanitize_claude_request_thinking_signatures_for_non_native(provider_request_body);
}
pub(crate) async fn resolve_local_standard_candidate_payload_parts( pub(crate) async fn resolve_local_standard_candidate_payload_parts(
state: &AppState, state: &AppState,
parts: &http::request::Parts, parts: &http::request::Parts,
@@ -379,6 +507,11 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
.await; .await;
return None; return None;
} }
apply_non_native_claude_thinking_signature_compat(
&mut provider_request_body,
provider_api_format,
transport,
);
if let Some(mapping) = if let Some(mapping) =
crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model( crate::system_features::reasoning_model_directive_mapping_for_api_format_and_model(
state, state,
@@ -422,6 +555,11 @@ pub(crate) async fn resolve_local_standard_candidate_payload_parts(
.await; .await;
return None; return None;
} }
apply_non_native_claude_thinking_signature_compat(
&mut provider_request_body,
provider_api_format,
transport,
);
} }
if let Some(kiro_auth) = kiro_auth.as_ref() { if let Some(kiro_auth) = kiro_auth.as_ref() {
@@ -799,3 +937,95 @@ async fn build_kiro_cross_format_payload_parts(
transport_profile: None, transport_profile: None,
}) })
} }
#[cfg(test)]
mod tests {
use super::{
provider_preserves_claude_thinking_signatures,
sanitize_claude_request_thinking_signatures_for_non_native,
};
use serde_json::json;
#[test]
fn sanitizes_historical_claude_thinking_for_non_native_relays() {
let mut body = json!({
"model": "claude-opus-4-1",
"messages": [{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "I should keep this short.",
"signature": "sig_123"
},
{
"type": "redacted_thinking",
"data": "opaque"
},
{
"type": "text",
"text": "Done."
}
]
}]
});
assert!(sanitize_claude_request_thinking_signatures_for_non_native(
&mut body
));
assert_eq!(body["messages"][0]["content"][0]["type"], json!("text"));
assert_eq!(
body["messages"][0]["content"][0]["text"],
json!("I should keep this short.")
);
assert_eq!(body["messages"][0]["content"].as_array().unwrap().len(), 2);
assert_eq!(body["messages"][0]["content"][1]["text"], json!("Done."));
}
#[test]
fn inserts_placeholder_when_only_redacted_thinking_would_remain() {
let mut body = json!({
"model": "claude-opus-4-1",
"messages": [{
"role": "assistant",
"content": [{
"type": "redacted_thinking",
"data": "opaque"
}]
}]
});
assert!(sanitize_claude_request_thinking_signatures_for_non_native(
&mut body
));
assert_eq!(body["messages"][0]["content"][0]["type"], json!("text"));
assert_eq!(
body["messages"][0]["content"][0]["text"],
json!("Previous thinking omitted.")
);
}
#[test]
fn official_claude_providers_preserve_thinking_signatures() {
assert!(provider_preserves_claude_thinking_signatures(
"anthropic",
"https://relay.example.com"
));
assert!(provider_preserves_claude_thinking_signatures(
"custom",
"https://api.anthropic.com"
));
assert!(provider_preserves_claude_thinking_signatures(
"aws",
"https://bedrock-runtime.us-east-1.amazonaws.com"
));
assert!(provider_preserves_claude_thinking_signatures(
"amazon_bedrock",
"https://relay.example.com"
));
assert!(!provider_preserves_claude_thinking_signatures(
"openai",
"https://relay.example.com"
));
}
}

View File

@@ -700,6 +700,18 @@ fn should_replace_stream_usage(
observed.is_more_complete_than(current) observed.is_more_complete_than(current)
} }
fn stream_terminal_summary_missing_observed_finish(
summary: Option<&ExecutionStreamTerminalSummary>,
) -> bool {
summary.is_some_and(|summary| {
!summary.observed_finish
&& !summary
.standardized_usage
.as_ref()
.is_some_and(StandardizedUsage::has_token_signal)
})
}
async fn execute_in_process_stream( async fn execute_in_process_stream(
state: &AppState, state: &AppState,
plan: &ExecutionPlan, plan: &ExecutionPlan,
@@ -3320,6 +3332,8 @@ async fn execute_stream_from_frame_stream(
report_context_owned.as_ref(), report_context_owned.as_ref(),
&mut stream_terminal_summary, &mut stream_terminal_summary,
); );
let missing_observed_finish =
stream_terminal_summary_missing_observed_finish(stream_terminal_summary.as_ref());
let should_submit_report = report_kind_owned.is_some(); let should_submit_report = report_kind_owned.is_some();
let terminal_telemetry = Some(build_terminal_stream_telemetry( let terminal_telemetry = Some(build_terminal_stream_telemetry(
@@ -3341,35 +3355,47 @@ async fn execute_stream_from_frame_stream(
stream_terminal_summary, stream_terminal_summary,
terminal_telemetry, terminal_telemetry,
); );
apply_local_execution_effect( if missing_observed_finish {
&state_for_report, warn!(
LocalExecutionEffectContext { event_name = "execution_runtime_stream_missing_terminal_event",
plan: &plan_for_report, log_type = "ops",
report_context: usage_payload.report_context.as_ref(), trace_id = %trace_id_owned,
}, request_id = %request_id_for_report_log,
LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect), candidate_id = ?candidate_id_for_report.as_deref(),
) status_code,
.await; "gateway stream ended before provider terminal event"
apply_local_execution_effect( );
&state_for_report, } else {
LocalExecutionEffectContext { apply_local_execution_effect(
plan: &plan_for_report, &state_for_report,
report_context: usage_payload.report_context.as_ref(), LocalExecutionEffectContext {
}, plan: &plan_for_report,
LocalExecutionEffect::AdaptiveSuccess(LocalAdaptiveSuccessEffect), report_context: usage_payload.report_context.as_ref(),
) },
.await; LocalExecutionEffect::HealthSuccess(LocalHealthSuccessEffect),
apply_local_execution_effect( )
&state_for_report, .await;
LocalExecutionEffectContext { apply_local_execution_effect(
plan: &plan_for_report, &state_for_report,
report_context: usage_payload.report_context.as_ref(), LocalExecutionEffectContext {
}, plan: &plan_for_report,
LocalExecutionEffect::PoolSuccessStream { report_context: usage_payload.report_context.as_ref(),
payload: &usage_payload, },
}, LocalExecutionEffect::AdaptiveSuccess(LocalAdaptiveSuccessEffect),
) )
.await; .await;
apply_local_execution_effect(
&state_for_report,
LocalExecutionEffectContext {
plan: &plan_for_report,
report_context: usage_payload.report_context.as_ref(),
},
LocalExecutionEffect::PoolSuccessStream {
payload: &usage_payload,
},
)
.await;
}
record_stream_terminal_usage( record_stream_terminal_usage(
&state_for_report, &state_for_report,
&plan_for_report, &plan_for_report,
@@ -3382,10 +3408,17 @@ async fn execute_stream_from_frame_stream(
&plan_for_report, &plan_for_report,
usage_payload.report_context.as_ref(), usage_payload.report_context.as_ref(),
SchedulerRequestCandidateStatusUpdate { SchedulerRequestCandidateStatusUpdate {
status: RequestCandidateStatus::Success, status: if missing_observed_finish {
RequestCandidateStatus::Failed
} else {
RequestCandidateStatus::Success
},
status_code: Some(status_code), status_code: Some(status_code),
error_type: None, error_type: missing_observed_finish
error_message: None, .then(|| "stream_missing_terminal_event".to_string()),
error_message: missing_observed_finish.then(|| {
"execution runtime stream ended before provider terminal event".to_string()
}),
latency_ms: usage_payload latency_ms: usage_payload
.telemetry .telemetry
.as_ref() .as_ref()
@@ -3487,6 +3520,7 @@ mod tests {
maybe_apply_kiro_prompt_cache_usage_to_stream_summary, merge_stream_terminal_summary, maybe_apply_kiro_prompt_cache_usage_to_stream_summary, merge_stream_terminal_summary,
should_limit_direct_finalize_prefetch, should_probe_success_failover_before_stream, should_limit_direct_finalize_prefetch, should_probe_success_failover_before_stream,
should_skip_direct_finalize_prefetch, stream_chunk_contains_sse_done, should_skip_direct_finalize_prefetch, stream_chunk_contains_sse_done,
stream_terminal_summary_missing_observed_finish,
}; };
use crate::control::GatewayControlDecision; use crate::control::GatewayControlDecision;
use crate::tunnel::{tunnel_protocol, TunnelProxyConn}; use crate::tunnel::{tunnel_protocol, TunnelProxyConn};
@@ -3564,6 +3598,35 @@ mod tests {
assert_eq!(merged.unknown_event_count, 3); assert_eq!(merged.unknown_event_count, 3);
} }
#[test]
fn detects_missing_observed_finish_only_without_usage_signal() {
assert!(stream_terminal_summary_missing_observed_finish(Some(
&ExecutionStreamTerminalSummary {
response_id: Some("resp_missing_finish".to_string()),
model: Some("gpt-5.5".to_string()),
observed_finish: false,
..ExecutionStreamTerminalSummary::default()
}
)));
let mut usage = StandardizedUsage::new();
usage.output_tokens = 12;
assert!(!stream_terminal_summary_missing_observed_finish(Some(
&ExecutionStreamTerminalSummary {
standardized_usage: Some(usage),
observed_finish: false,
..ExecutionStreamTerminalSummary::default()
}
)));
assert!(!stream_terminal_summary_missing_observed_finish(Some(
&ExecutionStreamTerminalSummary {
observed_finish: true,
..ExecutionStreamTerminalSummary::default()
}
)));
assert!(!stream_terminal_summary_missing_observed_finish(None));
}
#[test] #[test]
fn kiro_stream_summary_applies_prompt_cache_usage_from_original_request() { fn kiro_stream_summary_applies_prompt_cache_usage_from_original_request() {
let request_body = json!({ let request_body = json!({

View File

@@ -1291,6 +1291,8 @@ pub struct OpenAIChatClientEmitter {
model: Option<String>, model: Option<String>,
started: bool, started: bool,
finished: bool, finished: bool,
next_tool_call_index: usize,
tool_call_index_by_canonical: BTreeMap<usize, usize>,
} }
#[derive(Clone, Default)] #[derive(Clone, Default)]
@@ -1357,6 +1359,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> { pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
self.update_identity(&frame); self.update_identity(&frame);
match frame.event { match frame.event {
@@ -1465,6 +1478,7 @@ impl OpenAIChatClientEmitter {
name, name,
} => { } => {
let mut out = self.ensure_started()?; let mut out = self.ensure_started()?;
let chat_index = self.chat_tool_call_index(index);
out.extend(encode_json_sse( out.extend(encode_json_sse(
None, None,
&build_openai_chat_chunk( &build_openai_chat_chunk(
@@ -1474,7 +1488,7 @@ impl OpenAIChatClientEmitter {
self.model.as_deref().unwrap_or("unknown"), self.model.as_deref().unwrap_or("unknown"),
String::new(), String::new(),
Some(vec![json!({ Some(vec![json!({
"index": index, "index": chat_index,
"id": call_id, "id": call_id,
"type": "function", "type": "function",
"function": { "function": {
@@ -1489,6 +1503,7 @@ impl OpenAIChatClientEmitter {
} }
CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => { CanonicalStreamEvent::ToolCallArgumentsDelta { index, arguments } => {
let mut out = self.ensure_started()?; let mut out = self.ensure_started()?;
let chat_index = self.chat_tool_call_index(index);
out.extend(encode_json_sse( out.extend(encode_json_sse(
None, None,
&json!({ &json!({
@@ -1501,7 +1516,7 @@ impl OpenAIChatClientEmitter {
"index": 0, "index": 0,
"delta": { "delta": {
"tool_calls": [{ "tool_calls": [{
"index": index, "index": chat_index,
"function": { "function": {
"arguments": arguments, "arguments": arguments,
} }
@@ -2588,6 +2603,27 @@ mod tests {
parts 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] #[test]
fn openai_chat_provider_state_emits_unknown_events_for_unrecognized_deltas() { fn openai_chat_provider_state_emits_unknown_events_for_unrecognized_deltas() {
let mut state = OpenAIChatProviderState::default(); let mut state = OpenAIChatProviderState::default();
@@ -3250,6 +3286,50 @@ mod tests {
assert!(sse.contains("[Image]")); 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] #[test]
fn openai_chat_client_emitter_emits_usage_only_final_chunk() { fn openai_chat_client_emitter_emits_usage_only_final_chunk() {
let mut emitter = OpenAIChatClientEmitter::default(); let mut emitter = OpenAIChatClientEmitter::default();

View File

@@ -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> { fn build_stable_codex_prompt_cache_key_from_seed(kind: &str, seed: &str) -> Option<String> {
let normalized = user_api_key_id.trim(); let normalized = seed.trim();
if normalized.is_empty() { if normalized.is_empty() {
return None; 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!( 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(); let mut hasher = Sha1::new();
hasher.update(UUID_NAMESPACE_OID_BYTES); hasher.update(UUID_NAMESPACE_OID_BYTES);
@@ -186,6 +197,48 @@ fn build_stable_codex_prompt_cache_key(user_api_key_id: &str) -> Option<String>
Some(Uuid::from_bytes(bytes).to_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 build_short_codex_header_id(seed: &str) -> Option<String> { fn build_short_codex_header_id(seed: &str) -> Option<String> {
let normalized = seed.trim(); let normalized = seed.trim();
if normalized.is_empty() { if normalized.is_empty() {
@@ -271,11 +324,7 @@ fn maybe_inject_codex_prompt_cache_key(
return; return;
} }
let Some(body_object) = provider_request_body.as_object_mut() else { let existing = provider_request_body
return;
};
let existing = body_object
.get("prompt_cache_key") .get("prompt_cache_key")
.and_then(Value::as_str) .and_then(Value::as_str)
.map(str::trim) .map(str::trim)
@@ -284,8 +333,14 @@ fn maybe_inject_codex_prompt_cache_key(
return; return;
} }
let Some(prompt_cache_key) = user_api_key_id.and_then(build_stable_codex_prompt_cache_key) let prompt_cache_key = extract_codex_prompt_cache_session_seed(provider_request_body)
else { .and_then(|seed| build_stable_codex_prompt_cache_key_from_seed("session", &seed))
.or_else(|| user_api_key_id.and_then(build_stable_codex_prompt_cache_key));
let Some(prompt_cache_key) = prompt_cache_key else {
return;
};
let Some(body_object) = provider_request_body.as_object_mut() else {
return; return;
}; };
@@ -431,6 +486,13 @@ pub fn apply_codex_openai_responses_special_body_edits(
return; return;
} }
maybe_inject_codex_prompt_cache_key(
provider_request_body,
provider_type,
provider_api_format,
user_api_key_id,
);
let Some(body_object) = provider_request_body.as_object_mut() else { let Some(body_object) = provider_request_body.as_object_mut() else {
return; return;
}; };
@@ -478,13 +540,6 @@ pub fn apply_codex_openai_responses_special_body_edits(
apply_codex_openai_image_tool_overrides(body_object); apply_codex_openai_image_tool_overrides(body_object);
inject_codex_default_variation_prompt(body_object); 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,
);
} }
pub fn apply_codex_openai_responses_chat_body_edits( pub fn apply_codex_openai_responses_chat_body_edits(
@@ -748,6 +803,57 @@ 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] #[test]
fn compact_body_edits_strip_include_store_and_stream() { fn compact_body_edits_strip_include_store_and_stream() {
let mut provider_request_body = json!({ let mut provider_request_body = json!({

View File

@@ -158,6 +158,7 @@ pub struct StreamTerminalUsagePayloadSeed {
pub client_response: Option<Value>, pub client_response: Option<Value>,
pub client_response_body_state: Option<UsageBodyCaptureState>, pub client_response_body_state: Option<UsageBodyCaptureState>,
pub standardized_usage: Option<StandardizedUsage>, pub standardized_usage: Option<StandardizedUsage>,
pub observed_stream_finish: Option<bool>,
pub capture_metadata: Option<Value>, pub capture_metadata: Option<Value>,
} }
@@ -779,6 +780,10 @@ pub fn build_stream_terminal_usage_payload_seed(
let provider_response_headers = context_usage_value(context, "provider_response_headers") let provider_response_headers = context_usage_value(context, "provider_response_headers")
.or_else(|| headers_to_json(&payload.headers)); .or_else(|| headers_to_json(&payload.headers));
let client_response_headers = headers_to_json(&payload.headers); let client_response_headers = headers_to_json(&payload.headers);
let observed_stream_finish = payload
.terminal_summary
.as_ref()
.map(|summary| summary.observed_finish);
StreamTerminalUsagePayloadSeed { StreamTerminalUsagePayloadSeed {
report_kind: payload.report_kind.clone(), report_kind: payload.report_kind.clone(),
status_code: payload.status_code, status_code: payload.status_code,
@@ -797,6 +802,7 @@ pub fn build_stream_terminal_usage_payload_seed(
.terminal_summary .terminal_summary
.as_ref() .as_ref()
.and_then(|summary| summary.standardized_usage.clone()), .and_then(|summary| summary.standardized_usage.clone()),
observed_stream_finish,
capture_metadata: build_payload_body_capture_metadata( capture_metadata: build_payload_body_capture_metadata(
payload.provider_body_base64.as_deref(), payload.provider_body_base64.as_deref(),
payload.client_body_base64.as_deref(), payload.client_body_base64.as_deref(),
@@ -894,6 +900,7 @@ pub fn build_stream_terminal_usage_seed(
client_response, client_response,
client_response_body_state, client_response_body_state,
standardized_usage, standardized_usage,
observed_stream_finish,
capture_metadata, capture_metadata,
} = payload_seed; } = payload_seed;
let standardized_usage = standardized_usage.or_else(|| { let standardized_usage = standardized_usage.or_else(|| {
@@ -901,7 +908,16 @@ pub fn build_stream_terminal_usage_seed(
map_usage_from_response(response, context_seed.provider_contract.as_str()) map_usage_from_response(response, context_seed.provider_contract.as_str())
}) })
}); });
let terminal_state = infer_stream_terminal_state(report_kind.as_str(), status_code, cancelled); let missing_observed_finish = matches!(observed_stream_finish, Some(false))
&& !standardized_usage
.as_ref()
.is_some_and(StandardizedUsage::has_token_signal);
let terminal_state = infer_stream_terminal_state(
report_kind.as_str(),
status_code,
cancelled,
missing_observed_finish,
);
TerminalUsageSeed { TerminalUsageSeed {
terminal_state, terminal_state,
@@ -970,10 +986,11 @@ fn infer_stream_terminal_state(
report_kind: &str, report_kind: &str,
status_code: u16, status_code: u16,
cancelled: bool, cancelled: bool,
missing_observed_finish: bool,
) -> UsageTerminalState { ) -> UsageTerminalState {
if cancelled || status_code == 499 || report_kind.contains("cancel") { if cancelled || status_code == 499 || report_kind.contains("cancel") {
UsageTerminalState::Cancelled UsageTerminalState::Cancelled
} else if !(200..300).contains(&status_code) { } else if !(200..300).contains(&status_code) || missing_observed_finish {
UsageTerminalState::Failed UsageTerminalState::Failed
} else { } else {
UsageTerminalState::Completed UsageTerminalState::Completed
@@ -3811,6 +3828,69 @@ mod tests {
assert!(event.data.client_response_body.is_none()); assert!(event.data.client_response_body.is_none());
} }
#[test]
fn stream_terminal_usage_marks_missing_observed_finish_as_failed() {
let plan = ExecutionPlan {
request_id: "req-stream-missing-finish-1".to_string(),
candidate_id: Some("cand-stream-missing-finish-1".to_string()),
provider_name: Some("OpenAI".to_string()),
provider_id: "provider-1".to_string(),
endpoint_id: "endpoint-1".to_string(),
key_id: "key-1".to_string(),
method: "POST".to_string(),
url: "https://example.com/v1/responses".to_string(),
headers: BTreeMap::new(),
content_type: None,
content_encoding: None,
body: RequestBody {
json_body: None,
body_bytes_b64: None,
body_ref: None,
},
stream: true,
client_api_format: "openai:responses".to_string(),
provider_api_format: "openai:responses".to_string(),
model_name: Some("gpt-5.5".to_string()),
proxy: None,
transport_profile: None,
timeouts: None,
};
let payload = GatewayStreamReportRequest {
trace_id: "trace-stream-missing-finish-1".to_string(),
report_kind: "openai_responses_stream_success".to_string(),
report_context: Some(json!({
"client_api_format": "openai:responses",
"provider_api_format": "openai:responses"
})),
status_code: 200,
headers: BTreeMap::new(),
provider_body_base64: None,
provider_body_state: Some(UsageBodyCaptureState::None),
client_body_base64: None,
client_body_state: Some(UsageBodyCaptureState::None),
terminal_summary: Some(ExecutionStreamTerminalSummary {
response_id: Some("resp_missing_finish".to_string()),
model: Some("gpt-5.5".to_string()),
observed_finish: false,
..ExecutionStreamTerminalSummary::default()
}),
telemetry: None,
};
let event =
build_stream_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload)
.expect("usage event should build");
assert_eq!(event.event_type, UsageEventType::Failed);
assert_eq!(event.data.status_code, Some(200));
assert_eq!(
event.data.error_category.as_deref(),
Some("non_success_status")
);
assert_eq!(event.data.input_tokens, None);
assert_eq!(event.data.output_tokens, None);
}
#[test] #[test]
fn completed_image_usage_estimates_request_tokens_when_provider_usage_is_missing() { fn completed_image_usage_estimates_request_tokens_when_provider_usage_is_missing() {
let plan = ExecutionPlan { let plan = ExecutionPlan {