mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
fix(usage): handle terminal stream failures and preserve usage updates
This commit is contained in:
@@ -712,6 +712,15 @@ fn stream_terminal_summary_missing_observed_finish(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stream_terminal_summary_represents_failure(
|
||||||
|
summary: Option<&ExecutionStreamTerminalSummary>,
|
||||||
|
) -> bool {
|
||||||
|
summary.is_some_and(|summary| {
|
||||||
|
summary.parser_error.is_some()
|
||||||
|
|| stream_terminal_summary_missing_observed_finish(Some(summary))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn execute_in_process_stream(
|
async fn execute_in_process_stream(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
plan: &ExecutionPlan,
|
plan: &ExecutionPlan,
|
||||||
@@ -1434,7 +1443,12 @@ fn stream_chunk_contains_sse_done(chunk: &[u8]) -> bool {
|
|||||||
let line = line.trim();
|
let line = line.trim();
|
||||||
if matches!(
|
if matches!(
|
||||||
line,
|
line,
|
||||||
"data: [DONE]" | "event: message_stop" | "event: response.completed"
|
"data: [DONE]"
|
||||||
|
| "event: message_stop"
|
||||||
|
| "event: response.completed"
|
||||||
|
| "event: response.failed"
|
||||||
|
| "event: response.incomplete"
|
||||||
|
| "event: error"
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1447,7 +1461,14 @@ fn stream_chunk_contains_sse_done(chunk: &[u8]) -> bool {
|
|||||||
.get("type")
|
.get("type")
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.is_some_and(|event_type| {
|
.is_some_and(|event_type| {
|
||||||
matches!(event_type, "message_stop" | "response.completed")
|
matches!(
|
||||||
|
event_type,
|
||||||
|
"message_stop"
|
||||||
|
| "response.completed"
|
||||||
|
| "response.failed"
|
||||||
|
| "response.incomplete"
|
||||||
|
| "error"
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -3342,6 +3363,8 @@ async fn execute_stream_from_frame_stream(
|
|||||||
usage_stream_telemetry.as_ref(),
|
usage_stream_telemetry.as_ref(),
|
||||||
provider_stream_bytes.load(Ordering::Relaxed),
|
provider_stream_bytes.load(Ordering::Relaxed),
|
||||||
));
|
));
|
||||||
|
let stream_failed =
|
||||||
|
stream_terminal_summary_represents_failure(stream_terminal_summary.as_ref());
|
||||||
let usage_payload = build_stream_usage_payload(
|
let usage_payload = build_stream_usage_payload(
|
||||||
trace_id_owned.clone(),
|
trace_id_owned.clone(),
|
||||||
report_kind_owned.unwrap_or_default(),
|
report_kind_owned.unwrap_or_default(),
|
||||||
@@ -3408,17 +3431,34 @@ 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: if missing_observed_finish {
|
status: if stream_failed {
|
||||||
RequestCandidateStatus::Failed
|
RequestCandidateStatus::Failed
|
||||||
} else {
|
} else {
|
||||||
RequestCandidateStatus::Success
|
RequestCandidateStatus::Success
|
||||||
},
|
},
|
||||||
status_code: Some(status_code),
|
status_code: Some(status_code),
|
||||||
error_type: missing_observed_finish
|
error_type: if stream_failed {
|
||||||
.then(|| "stream_missing_terminal_event".to_string()),
|
if missing_observed_finish {
|
||||||
error_message: missing_observed_finish.then(|| {
|
Some("stream_missing_terminal_event".to_string())
|
||||||
"execution runtime stream ended before provider terminal event".to_string()
|
} else {
|
||||||
}),
|
Some("stream_terminal_error".to_string())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
error_message: if stream_failed {
|
||||||
|
stream_terminal_summary
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|summary| summary.parser_error.clone())
|
||||||
|
.or_else(|| {
|
||||||
|
missing_observed_finish.then(|| {
|
||||||
|
"execution runtime stream ended before provider terminal event"
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
latency_ms: usage_payload
|
latency_ms: usage_payload
|
||||||
.telemetry
|
.telemetry
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -3546,6 +3586,9 @@ mod tests {
|
|||||||
assert!(stream_chunk_contains_sse_done(
|
assert!(stream_chunk_contains_sse_done(
|
||||||
b"event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{}}\n\n"
|
b"event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{}}\n\n"
|
||||||
));
|
));
|
||||||
|
assert!(stream_chunk_contains_sse_done(
|
||||||
|
b"event: response.failed\ndata: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\"}}\n\n"
|
||||||
|
));
|
||||||
assert!(!stream_chunk_contains_sse_done(
|
assert!(!stream_chunk_contains_sse_done(
|
||||||
b"event: content_block_delta\ndata: {\"type\":\"content_block_delta\"}\n\n"
|
b"event: content_block_delta\ndata: {\"type\":\"content_block_delta\"}\n\n"
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ pub(crate) fn snapshot_local_request_candidate_status(
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())?;
|
.filter(|value| !value.is_empty())?;
|
||||||
let metadata = parse_request_candidate_report_context(report_context)?;
|
let metadata = parse_request_candidate_report_context(report_context)?;
|
||||||
let candidate_index = metadata.candidate_index?;
|
let candidate_index = metadata.candidate_index.unwrap_or(0);
|
||||||
|
|
||||||
Some(LocalRequestCandidateStatusSnapshot {
|
Some(LocalRequestCandidateStatusSnapshot {
|
||||||
candidate_id: candidate_id.to_string(),
|
candidate_id: candidate_id.to_string(),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use context::{report_context_is_locally_actionable, resolve_locally_actionable_r
|
|||||||
use aether_usage_runtime::{
|
use aether_usage_runtime::{
|
||||||
is_local_ai_stream_report_kind, is_local_ai_sync_report_kind, report_request_id,
|
is_local_ai_stream_report_kind, is_local_ai_sync_report_kind, report_request_id,
|
||||||
should_handle_local_stream_report, should_handle_local_sync_report,
|
should_handle_local_stream_report, should_handle_local_sync_report,
|
||||||
sync_report_represents_failure,
|
stream_report_represents_failure, sync_report_represents_failure,
|
||||||
};
|
};
|
||||||
pub(crate) use aether_usage_runtime::{GatewayStreamReportRequest, GatewaySyncReportRequest};
|
pub(crate) use aether_usage_runtime::{GatewayStreamReportRequest, GatewaySyncReportRequest};
|
||||||
|
|
||||||
@@ -256,14 +256,33 @@ async fn handle_local_stream_report(state: &AppState, payload: &GatewayStreamRep
|
|||||||
.telemetry
|
.telemetry
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|telemetry| telemetry.elapsed_ms);
|
.and_then(|telemetry| telemetry.elapsed_ms);
|
||||||
|
let failed = stream_report_represents_failure(payload);
|
||||||
record_report_request_candidate_status(
|
record_report_request_candidate_status(
|
||||||
state,
|
state,
|
||||||
payload.report_context.as_ref(),
|
payload.report_context.as_ref(),
|
||||||
SchedulerRequestCandidateStatusUpdate {
|
SchedulerRequestCandidateStatusUpdate {
|
||||||
status: RequestCandidateStatus::Success,
|
status: if failed {
|
||||||
|
RequestCandidateStatus::Failed
|
||||||
|
} else {
|
||||||
|
RequestCandidateStatus::Success
|
||||||
|
},
|
||||||
status_code: Some(payload.status_code),
|
status_code: Some(payload.status_code),
|
||||||
error_type: None,
|
error_type: failed.then(|| {
|
||||||
error_message: None,
|
if payload.status_code >= 400 {
|
||||||
|
"stream_http_error".to_string()
|
||||||
|
} else {
|
||||||
|
"stream_terminal_error".to_string()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
error_message: failed.then(|| {
|
||||||
|
payload
|
||||||
|
.terminal_summary
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|summary| summary.parser_error.clone())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
"execution runtime stream ended with a terminal error".to_string()
|
||||||
|
})
|
||||||
|
}),
|
||||||
latency_ms,
|
latency_ms,
|
||||||
started_at_unix_ms: None,
|
started_at_unix_ms: None,
|
||||||
finished_at_unix_ms: Some(terminal_unix_ms),
|
finished_at_unix_ms: Some(terminal_unix_ms),
|
||||||
|
|||||||
@@ -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" => {
|
"response.completed" => {
|
||||||
let Some(response) = value.get("response").and_then(Value::as_object) else {
|
let Some(response) = value.get("response").and_then(Value::as_object) else {
|
||||||
return Ok(out);
|
return Ok(out);
|
||||||
@@ -1559,6 +1576,13 @@ impl OpenAIChatClientEmitter {
|
|||||||
)?);
|
)?);
|
||||||
Ok(out)
|
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::UnknownEvent(_) => Ok(Vec::new()),
|
||||||
CanonicalStreamEvent::Finish {
|
CanonicalStreamEvent::Finish {
|
||||||
finish_reason,
|
finish_reason,
|
||||||
@@ -2456,6 +2480,29 @@ impl OpenAIResponsesClientEmitter {
|
|||||||
}
|
}
|
||||||
Ok(out)
|
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::UnknownEvent(_) => Ok(Vec::new()),
|
||||||
CanonicalStreamEvent::Finish { usage, .. } => {
|
CanonicalStreamEvent::Finish { usage, .. } => {
|
||||||
if self.finished {
|
if self.finished {
|
||||||
@@ -2682,6 +2729,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]
|
#[test]
|
||||||
fn openai_usage_derives_missing_input_tokens_from_total() {
|
fn openai_usage_derives_missing_input_tokens_from_total() {
|
||||||
let usage = canonical_usage_from_openai_usage(Some(&json!({
|
let usage = canonical_usage_from_openai_usage(Some(&json!({
|
||||||
@@ -2848,6 +2929,41 @@ mod tests {
|
|||||||
assert_eq!(response_sequence_numbers(&sse), (1..=9).collect::<Vec<_>>());
|
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]
|
#[test]
|
||||||
fn openai_responses_client_emitter_keeps_text_item_id_stable_after_text_started() {
|
fn openai_responses_client_emitter_keeps_text_item_id_stable_after_text_started() {
|
||||||
let mut emitter = OpenAIResponsesClientEmitter::default();
|
let mut emitter = OpenAIResponsesClientEmitter::default();
|
||||||
|
|||||||
@@ -239,6 +239,224 @@ fn extract_codex_prompt_cache_session_seed(provider_request_body: &Value) -> Opt
|
|||||||
.or_else(|| object.get("metadata").and_then(session_seed_from_metadata))
|
.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> {
|
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() {
|
||||||
@@ -335,6 +553,14 @@ fn maybe_inject_codex_prompt_cache_key(
|
|||||||
|
|
||||||
let prompt_cache_key = extract_codex_prompt_cache_session_seed(provider_request_body)
|
let prompt_cache_key = extract_codex_prompt_cache_session_seed(provider_request_body)
|
||||||
.and_then(|seed| build_stable_codex_prompt_cache_key_from_seed("session", &seed))
|
.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));
|
.or_else(|| user_api_key_id.and_then(build_stable_codex_prompt_cache_key));
|
||||||
let Some(prompt_cache_key) = prompt_cache_key else {
|
let Some(prompt_cache_key) = prompt_cache_key else {
|
||||||
return;
|
return;
|
||||||
@@ -854,6 +1080,157 @@ mod tests {
|
|||||||
assert!(body_c.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]
|
#[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!({
|
||||||
|
|||||||
@@ -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> {
|
pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<CanonicalUsage> {
|
||||||
let usage = value?.as_object()?;
|
let usage = value?.as_object()?;
|
||||||
let input_tokens = usage
|
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::sse::encode_json_sse;
|
||||||
use crate::formats::shared::stream_core::common::{
|
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;
|
use crate::formats::shared::AiSurfaceFinalizeError;
|
||||||
|
|
||||||
@@ -197,6 +198,14 @@ impl StreamingStandardTerminalObserver {
|
|||||||
summary.model = Some(model);
|
summary.model = Some(model);
|
||||||
}
|
}
|
||||||
match event {
|
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(_) => {
|
CanonicalStreamEvent::UnknownEvent(_) => {
|
||||||
summary.unknown_event_count = summary.unknown_event_count.saturating_add(1);
|
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)> {
|
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 message = error.get("message").and_then(Value::as_str)?.to_string();
|
||||||
let code = error
|
let code = error
|
||||||
.get("code")
|
.get("code")
|
||||||
@@ -973,6 +983,41 @@ mod tests {
|
|||||||
assert!(!summary.observed_finish);
|
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]
|
#[test]
|
||||||
fn terminal_observer_tracks_openai_image_stream_usage() {
|
fn terminal_observer_tracks_openai_image_stream_usage() {
|
||||||
let mut report_context = report_context("openai:image", "openai:chat");
|
let mut report_context = report_context("openai:image", "openai:chat");
|
||||||
|
|||||||
@@ -183,23 +183,35 @@ DO UPDATE SET
|
|||||||
total_cost_usd = CASE WHEN "usage".billing_status = 'pending' AND EXCLUDED.status IN ('completed', 'failed', 'cancelled') THEN GREATEST("usage".total_cost_usd, EXCLUDED.total_cost_usd) ELSE "usage".total_cost_usd END,
|
total_cost_usd = CASE WHEN "usage".billing_status = 'pending' AND EXCLUDED.status IN ('completed', 'failed', 'cancelled') THEN GREATEST("usage".total_cost_usd, EXCLUDED.total_cost_usd) ELSE "usage".total_cost_usd END,
|
||||||
actual_total_cost_usd = CASE WHEN "usage".billing_status = 'pending' AND EXCLUDED.status IN ('completed', 'failed', 'cancelled') THEN GREATEST("usage".actual_total_cost_usd, EXCLUDED.actual_total_cost_usd) ELSE "usage".actual_total_cost_usd END,
|
actual_total_cost_usd = CASE WHEN "usage".billing_status = 'pending' AND EXCLUDED.status IN ('completed', 'failed', 'cancelled') THEN GREATEST("usage".actual_total_cost_usd, EXCLUDED.actual_total_cost_usd) ELSE "usage".actual_total_cost_usd END,
|
||||||
status_code = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
status_code = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
|
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND EXCLUDED.status IN ('pending', 'streaming') THEN "usage".status_code
|
||||||
WHEN "usage".status = 'streaming' AND EXCLUDED.status = 'pending' THEN "usage".status_code
|
WHEN "usage".status = 'streaming' AND EXCLUDED.status = 'pending' THEN "usage".status_code
|
||||||
WHEN EXCLUDED.status IN ('pending', 'streaming', 'completed', 'cancelled') AND EXCLUDED.status_code IS NULL THEN NULL
|
WHEN EXCLUDED.status IN ('pending', 'streaming', 'completed', 'cancelled') AND EXCLUDED.status_code IS NULL THEN NULL
|
||||||
ELSE COALESCE(EXCLUDED.status_code, "usage".status_code)
|
ELSE COALESCE(EXCLUDED.status_code, "usage".status_code)
|
||||||
END ELSE "usage".status_code END,
|
END ELSE "usage".status_code END,
|
||||||
error_message = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
error_message = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
|
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND EXCLUDED.status IN ('pending', 'streaming') THEN "usage".error_message
|
||||||
WHEN "usage".status = 'streaming' AND EXCLUDED.status = 'pending' THEN "usage".error_message
|
WHEN "usage".status = 'streaming' AND EXCLUDED.status = 'pending' THEN "usage".error_message
|
||||||
WHEN EXCLUDED.status IN ('pending', 'streaming', 'completed', 'cancelled') THEN EXCLUDED.error_message
|
WHEN EXCLUDED.status IN ('pending', 'streaming', 'completed', 'cancelled') THEN EXCLUDED.error_message
|
||||||
ELSE COALESCE(EXCLUDED.error_message, "usage".error_message)
|
ELSE COALESCE(EXCLUDED.error_message, "usage".error_message)
|
||||||
END ELSE "usage".error_message END,
|
END ELSE "usage".error_message END,
|
||||||
error_category = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
error_category = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
|
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND EXCLUDED.status IN ('pending', 'streaming') THEN "usage".error_category
|
||||||
WHEN "usage".status = 'streaming' AND EXCLUDED.status = 'pending' THEN "usage".error_category
|
WHEN "usage".status = 'streaming' AND EXCLUDED.status = 'pending' THEN "usage".error_category
|
||||||
WHEN EXCLUDED.status IN ('pending', 'streaming', 'completed', 'cancelled') THEN EXCLUDED.error_category
|
WHEN EXCLUDED.status IN ('pending', 'streaming', 'completed', 'cancelled') THEN EXCLUDED.error_category
|
||||||
ELSE COALESCE(EXCLUDED.error_category, "usage".error_category)
|
ELSE COALESCE(EXCLUDED.error_category, "usage".error_category)
|
||||||
END ELSE "usage".error_category END,
|
END ELSE "usage".error_category END,
|
||||||
response_time_ms = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.response_time_ms, "usage".response_time_ms) ELSE "usage".response_time_ms END,
|
response_time_ms = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
first_byte_time_ms = CASE WHEN "usage".billing_status = 'pending' THEN COALESCE(EXCLUDED.first_byte_time_ms, "usage".first_byte_time_ms) ELSE "usage".first_byte_time_ms END,
|
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND EXCLUDED.status IN ('pending', 'streaming') THEN "usage".response_time_ms
|
||||||
|
WHEN EXCLUDED.response_time_ms IS NULL OR EXCLUDED.response_time_ms = 0 THEN COALESCE("usage".response_time_ms, EXCLUDED.response_time_ms)
|
||||||
|
ELSE EXCLUDED.response_time_ms
|
||||||
|
END ELSE "usage".response_time_ms END,
|
||||||
|
first_byte_time_ms = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
|
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND EXCLUDED.status IN ('pending', 'streaming') THEN "usage".first_byte_time_ms
|
||||||
|
WHEN EXCLUDED.first_byte_time_ms IS NULL OR EXCLUDED.first_byte_time_ms = 0 THEN COALESCE("usage".first_byte_time_ms, EXCLUDED.first_byte_time_ms)
|
||||||
|
ELSE EXCLUDED.first_byte_time_ms
|
||||||
|
END ELSE "usage".first_byte_time_ms END,
|
||||||
status = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
status = CASE WHEN "usage".billing_status = 'pending' THEN CASE
|
||||||
|
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND EXCLUDED.status IN ('pending', 'streaming') THEN "usage".status
|
||||||
WHEN "usage".status = 'streaming' AND EXCLUDED.status = 'pending' THEN "usage".status
|
WHEN "usage".status = 'streaming' AND EXCLUDED.status = 'pending' THEN "usage".status
|
||||||
ELSE EXCLUDED.status
|
ELSE EXCLUDED.status
|
||||||
END ELSE "usage".status END,
|
END ELSE "usage".status END,
|
||||||
|
|||||||
@@ -182,17 +182,41 @@ ON CONFLICT (request_id) DO UPDATE SET
|
|||||||
output_price_per_1m = excluded.output_price_per_1m,
|
output_price_per_1m = excluded.output_price_per_1m,
|
||||||
total_cost_usd = excluded.total_cost_usd,
|
total_cost_usd = excluded.total_cost_usd,
|
||||||
actual_total_cost_usd = excluded.actual_total_cost_usd,
|
actual_total_cost_usd = excluded.actual_total_cost_usd,
|
||||||
status_code = excluded.status_code,
|
status_code = CASE
|
||||||
error_message = excluded.error_message,
|
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".status_code
|
||||||
error_category = excluded.error_category,
|
WHEN "usage".status = 'streaming' AND excluded.status = 'pending' THEN "usage".status_code
|
||||||
response_time_ms = excluded.response_time_ms,
|
ELSE excluded.status_code
|
||||||
first_byte_time_ms = excluded.first_byte_time_ms,
|
END,
|
||||||
status = excluded.status,
|
error_message = CASE
|
||||||
|
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".error_message
|
||||||
|
WHEN "usage".status = 'streaming' AND excluded.status = 'pending' THEN "usage".error_message
|
||||||
|
ELSE excluded.error_message
|
||||||
|
END,
|
||||||
|
error_category = CASE
|
||||||
|
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".error_category
|
||||||
|
WHEN "usage".status = 'streaming' AND excluded.status = 'pending' THEN "usage".error_category
|
||||||
|
ELSE excluded.error_category
|
||||||
|
END,
|
||||||
|
response_time_ms = CASE
|
||||||
|
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".response_time_ms
|
||||||
|
WHEN excluded.response_time_ms IS NULL OR excluded.response_time_ms = 0 THEN COALESCE("usage".response_time_ms, excluded.response_time_ms)
|
||||||
|
ELSE excluded.response_time_ms
|
||||||
|
END,
|
||||||
|
first_byte_time_ms = CASE
|
||||||
|
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".first_byte_time_ms
|
||||||
|
WHEN excluded.first_byte_time_ms IS NULL OR excluded.first_byte_time_ms = 0 THEN COALESCE("usage".first_byte_time_ms, excluded.first_byte_time_ms)
|
||||||
|
ELSE excluded.first_byte_time_ms
|
||||||
|
END,
|
||||||
|
status = CASE
|
||||||
|
WHEN "usage".status IN ('completed', 'failed', 'cancelled') AND excluded.status IN ('pending', 'streaming') THEN "usage".status
|
||||||
|
WHEN "usage".status = 'streaming' AND excluded.status = 'pending' THEN "usage".status
|
||||||
|
ELSE excluded.status
|
||||||
|
END,
|
||||||
billing_status = excluded.billing_status,
|
billing_status = excluded.billing_status,
|
||||||
request_metadata = excluded.request_metadata,
|
request_metadata = excluded.request_metadata,
|
||||||
candidate_id = excluded.candidate_id,
|
candidate_id = COALESCE(excluded.candidate_id, "usage".candidate_id),
|
||||||
candidate_index = excluded.candidate_index,
|
candidate_index = COALESCE(excluded.candidate_index, "usage".candidate_index),
|
||||||
key_name = excluded.key_name,
|
key_name = COALESCE(excluded.key_name, "usage".key_name),
|
||||||
planner_kind = excluded.planner_kind,
|
planner_kind = excluded.planner_kind,
|
||||||
route_family = excluded.route_family,
|
route_family = excluded.route_family,
|
||||||
route_kind = excluded.route_kind,
|
route_kind = excluded.route_kind,
|
||||||
|
|||||||
@@ -463,7 +463,7 @@ pub fn build_local_request_candidate_status_record(
|
|||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())?;
|
.filter(|value| !value.is_empty())?;
|
||||||
let metadata = parse_request_candidate_report_context(report_context)?;
|
let metadata = parse_request_candidate_report_context(report_context)?;
|
||||||
let candidate_index = metadata.candidate_index?;
|
let candidate_index = metadata.candidate_index.unwrap_or(0);
|
||||||
let extra_data = build_report_candidate_extra_data(ReportCandidateExtraDataInput {
|
let extra_data = build_report_candidate_extra_data(ReportCandidateExtraDataInput {
|
||||||
client_api_format: metadata.client_api_format.clone(),
|
client_api_format: metadata.client_api_format.clone(),
|
||||||
provider_api_format: metadata.provider_api_format.clone(),
|
provider_api_format: metadata.provider_api_format.clone(),
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ pub use report::{
|
|||||||
infer_internal_finalize_signature, is_local_ai_stream_report_kind,
|
infer_internal_finalize_signature, is_local_ai_stream_report_kind,
|
||||||
is_local_ai_sync_report_kind, normalize_gemini_file_name, report_request_id,
|
is_local_ai_sync_report_kind, normalize_gemini_file_name, report_request_id,
|
||||||
resolve_internal_finalize_route, should_handle_local_stream_report,
|
resolve_internal_finalize_route, should_handle_local_stream_report,
|
||||||
should_handle_local_sync_report, sync_report_represents_failure, GatewayStreamReportRequest,
|
should_handle_local_sync_report, stream_report_represents_failure,
|
||||||
GatewaySyncReportRequest, GeminiFileMappingEntry, InternalFinalizeRoute,
|
sync_report_represents_failure, GatewayStreamReportRequest, GatewaySyncReportRequest,
|
||||||
GEMINI_FILE_MAPPING_TTL_SECONDS,
|
GeminiFileMappingEntry, InternalFinalizeRoute, GEMINI_FILE_MAPPING_TTL_SECONDS,
|
||||||
};
|
};
|
||||||
pub use report_context::{
|
pub use report_context::{
|
||||||
build_locally_actionable_report_context_from_request_candidate,
|
build_locally_actionable_report_context_from_request_candidate,
|
||||||
|
|||||||
@@ -292,6 +292,24 @@ pub fn sync_report_represents_failure(
|
|||||||
.is_some_and(|value| !value.is_null())
|
.is_some_and(|value| !value.is_null())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn stream_terminal_summary_represents_failure(summary: &ExecutionStreamTerminalSummary) -> bool {
|
||||||
|
summary.parser_error.is_some()
|
||||||
|
|| (!summary.observed_finish
|
||||||
|
&& !summary
|
||||||
|
.standardized_usage
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(aether_contracts::StandardizedUsage::has_token_signal))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stream_report_represents_failure(payload: &GatewayStreamReportRequest) -> bool {
|
||||||
|
payload.status_code >= 400
|
||||||
|
|| payload.report_kind.contains("error")
|
||||||
|
|| payload
|
||||||
|
.terminal_summary
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(stream_terminal_summary_represents_failure)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn should_handle_local_sync_report(
|
pub fn should_handle_local_sync_report(
|
||||||
report_context: Option<&serde_json::Value>,
|
report_context: Option<&serde_json::Value>,
|
||||||
report_kind: &str,
|
report_kind: &str,
|
||||||
@@ -373,6 +391,7 @@ fn content_type_starts_with(headers: &BTreeMap<String, String>, expected_prefix:
|
|||||||
mod tests {
|
mod tests {
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use aether_contracts::ExecutionStreamTerminalSummary;
|
||||||
use base64::Engine as _;
|
use base64::Engine as _;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -381,7 +400,8 @@ mod tests {
|
|||||||
infer_internal_finalize_signature, is_local_ai_stream_report_kind,
|
infer_internal_finalize_signature, is_local_ai_stream_report_kind,
|
||||||
is_local_ai_sync_report_kind, normalize_gemini_file_name, report_request_id,
|
is_local_ai_sync_report_kind, normalize_gemini_file_name, report_request_id,
|
||||||
resolve_internal_finalize_route, should_handle_local_stream_report,
|
resolve_internal_finalize_route, should_handle_local_stream_report,
|
||||||
should_handle_local_sync_report, sync_report_represents_failure, GatewaySyncReportRequest,
|
should_handle_local_sync_report, stream_report_represents_failure,
|
||||||
|
sync_report_represents_failure, GatewayStreamReportRequest, GatewaySyncReportRequest,
|
||||||
GeminiFileMappingEntry, InternalFinalizeRoute,
|
GeminiFileMappingEntry, InternalFinalizeRoute,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -416,6 +436,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sample_stream_report(report_kind: &str, status_code: u16) -> GatewayStreamReportRequest {
|
||||||
|
GatewayStreamReportRequest {
|
||||||
|
trace_id: "trace-stream-123".to_string(),
|
||||||
|
report_kind: report_kind.to_string(),
|
||||||
|
report_context: None,
|
||||||
|
status_code,
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
provider_body_base64: None,
|
||||||
|
provider_body_state: None,
|
||||||
|
client_body_base64: None,
|
||||||
|
client_body_state: None,
|
||||||
|
terminal_summary: None,
|
||||||
|
telemetry: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn classifies_local_ai_sync_report_kinds() {
|
fn classifies_local_ai_sync_report_kinds() {
|
||||||
assert!(is_local_ai_sync_report_kind(
|
assert!(is_local_ai_sync_report_kind(
|
||||||
@@ -481,6 +517,18 @@ mod tests {
|
|||||||
assert!(!sync_report_represents_failure(&success_payload, None));
|
assert!(!sync_report_represents_failure(&success_payload, None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detects_stream_report_failure_from_terminal_summary_error() {
|
||||||
|
let mut payload = sample_stream_report("openai_responses_stream_success", 200);
|
||||||
|
payload.terminal_summary = Some(ExecutionStreamTerminalSummary {
|
||||||
|
observed_finish: true,
|
||||||
|
parser_error: Some("policy failure".to_string()),
|
||||||
|
..ExecutionStreamTerminalSummary::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
assert!(stream_report_represents_failure(&payload));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn infers_internal_finalize_signature_from_context_or_report_kind() {
|
fn infers_internal_finalize_signature_from_context_or_report_kind() {
|
||||||
let from_context = sample_sync_report_with_context(
|
let from_context = sample_sync_report_with_context(
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ pub struct StreamTerminalUsagePayloadSeed {
|
|||||||
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 observed_stream_finish: Option<bool>,
|
||||||
|
pub terminal_error_message: Option<String>,
|
||||||
pub capture_metadata: Option<Value>,
|
pub capture_metadata: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +185,7 @@ pub struct TerminalUsageSeed {
|
|||||||
pub has_format_conversion: bool,
|
pub has_format_conversion: bool,
|
||||||
pub is_stream: bool,
|
pub is_stream: bool,
|
||||||
pub status_code: u16,
|
pub status_code: u16,
|
||||||
|
pub terminal_error_message: Option<String>,
|
||||||
pub response_time_ms: Option<u64>,
|
pub response_time_ms: Option<u64>,
|
||||||
pub first_byte_time_ms: Option<u64>,
|
pub first_byte_time_ms: Option<u64>,
|
||||||
pub request_headers: Option<Value>,
|
pub request_headers: Option<Value>,
|
||||||
@@ -507,6 +509,7 @@ fn build_terminal_usage_event_from_seed_impl(
|
|||||||
has_format_conversion,
|
has_format_conversion,
|
||||||
is_stream,
|
is_stream,
|
||||||
status_code,
|
status_code,
|
||||||
|
terminal_error_message,
|
||||||
response_time_ms,
|
response_time_ms,
|
||||||
first_byte_time_ms,
|
first_byte_time_ms,
|
||||||
request_headers,
|
request_headers,
|
||||||
@@ -531,7 +534,8 @@ fn build_terminal_usage_event_from_seed_impl(
|
|||||||
};
|
};
|
||||||
let routing = merge_routing_seed_with_metadata_owned(routing, request_metadata.as_ref());
|
let routing = merge_routing_seed_with_metadata_owned(routing, request_metadata.as_ref());
|
||||||
let body_refs = merge_body_refs_seed_with_metadata_owned(body_refs, request_metadata.as_ref());
|
let body_refs = merge_body_refs_seed_with_metadata_owned(body_refs, request_metadata.as_ref());
|
||||||
let error_message = resolve_error_message(status_code, provider_response.as_ref(), None)
|
let error_message = terminal_error_message
|
||||||
|
.or_else(|| resolve_error_message(status_code, provider_response.as_ref(), None))
|
||||||
.or_else(|| resolve_error_message(status_code, client_response.as_ref(), None));
|
.or_else(|| resolve_error_message(status_code, client_response.as_ref(), None));
|
||||||
let api_family = infer_api_family(&client_contract).map(ToOwned::to_owned);
|
let api_family = infer_api_family(&client_contract).map(ToOwned::to_owned);
|
||||||
let endpoint_kind = infer_endpoint_kind(&client_contract).map(ToOwned::to_owned);
|
let endpoint_kind = infer_endpoint_kind(&client_contract).map(ToOwned::to_owned);
|
||||||
@@ -784,6 +788,12 @@ pub fn build_stream_terminal_usage_payload_seed(
|
|||||||
.terminal_summary
|
.terminal_summary
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|summary| summary.observed_finish);
|
.map(|summary| summary.observed_finish);
|
||||||
|
let terminal_error_message = payload
|
||||||
|
.terminal_summary
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|summary| summary.parser_error.clone())
|
||||||
|
.map(|message| message.trim().to_string())
|
||||||
|
.filter(|message| !message.is_empty());
|
||||||
StreamTerminalUsagePayloadSeed {
|
StreamTerminalUsagePayloadSeed {
|
||||||
report_kind: payload.report_kind.clone(),
|
report_kind: payload.report_kind.clone(),
|
||||||
status_code: payload.status_code,
|
status_code: payload.status_code,
|
||||||
@@ -803,6 +813,7 @@ pub fn build_stream_terminal_usage_payload_seed(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|summary| summary.standardized_usage.clone()),
|
.and_then(|summary| summary.standardized_usage.clone()),
|
||||||
observed_stream_finish,
|
observed_stream_finish,
|
||||||
|
terminal_error_message,
|
||||||
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(),
|
||||||
@@ -859,6 +870,7 @@ pub fn build_sync_terminal_usage_seed(
|
|||||||
has_format_conversion: context_seed.has_format_conversion,
|
has_format_conversion: context_seed.has_format_conversion,
|
||||||
is_stream: context_seed.is_stream,
|
is_stream: context_seed.is_stream,
|
||||||
status_code,
|
status_code,
|
||||||
|
terminal_error_message: None,
|
||||||
response_time_ms,
|
response_time_ms,
|
||||||
first_byte_time_ms,
|
first_byte_time_ms,
|
||||||
request_headers: context_seed.request_headers,
|
request_headers: context_seed.request_headers,
|
||||||
@@ -901,6 +913,7 @@ pub fn build_stream_terminal_usage_seed(
|
|||||||
client_response_body_state,
|
client_response_body_state,
|
||||||
standardized_usage,
|
standardized_usage,
|
||||||
observed_stream_finish,
|
observed_stream_finish,
|
||||||
|
terminal_error_message,
|
||||||
capture_metadata,
|
capture_metadata,
|
||||||
} = payload_seed;
|
} = payload_seed;
|
||||||
let standardized_usage = standardized_usage.or_else(|| {
|
let standardized_usage = standardized_usage.or_else(|| {
|
||||||
@@ -912,11 +925,23 @@ pub fn build_stream_terminal_usage_seed(
|
|||||||
&& !standardized_usage
|
&& !standardized_usage
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(StandardizedUsage::has_token_signal);
|
.is_some_and(StandardizedUsage::has_token_signal);
|
||||||
|
let terminal_error_message = terminal_error_message
|
||||||
|
.or_else(|| {
|
||||||
|
provider_response_full
|
||||||
|
.as_ref()
|
||||||
|
.and_then(extract_explicit_error_message_from_json)
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
client_response
|
||||||
|
.as_ref()
|
||||||
|
.and_then(extract_explicit_error_message_from_json)
|
||||||
|
});
|
||||||
let terminal_state = infer_stream_terminal_state(
|
let terminal_state = infer_stream_terminal_state(
|
||||||
report_kind.as_str(),
|
report_kind.as_str(),
|
||||||
status_code,
|
status_code,
|
||||||
cancelled,
|
cancelled,
|
||||||
missing_observed_finish,
|
missing_observed_finish,
|
||||||
|
terminal_error_message.is_some(),
|
||||||
);
|
);
|
||||||
|
|
||||||
TerminalUsageSeed {
|
TerminalUsageSeed {
|
||||||
@@ -940,6 +965,7 @@ pub fn build_stream_terminal_usage_seed(
|
|||||||
has_format_conversion: context_seed.has_format_conversion,
|
has_format_conversion: context_seed.has_format_conversion,
|
||||||
is_stream: context_seed.is_stream,
|
is_stream: context_seed.is_stream,
|
||||||
status_code,
|
status_code,
|
||||||
|
terminal_error_message,
|
||||||
response_time_ms,
|
response_time_ms,
|
||||||
first_byte_time_ms,
|
first_byte_time_ms,
|
||||||
request_headers: context_seed.request_headers,
|
request_headers: context_seed.request_headers,
|
||||||
@@ -987,10 +1013,11 @@ fn infer_stream_terminal_state(
|
|||||||
status_code: u16,
|
status_code: u16,
|
||||||
cancelled: bool,
|
cancelled: bool,
|
||||||
missing_observed_finish: bool,
|
missing_observed_finish: bool,
|
||||||
|
terminal_error: 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) || missing_observed_finish {
|
} else if !(200..300).contains(&status_code) || missing_observed_finish || terminal_error {
|
||||||
UsageTerminalState::Failed
|
UsageTerminalState::Failed
|
||||||
} else {
|
} else {
|
||||||
UsageTerminalState::Completed
|
UsageTerminalState::Completed
|
||||||
@@ -2112,6 +2139,31 @@ fn extract_explicit_error_message_from_json(value: &Value) -> Option<String> {
|
|||||||
.and_then(|error| error.get("message"))
|
.and_then(|error| error.get("message"))
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
|
.or_else(|| {
|
||||||
|
value
|
||||||
|
.get("response")
|
||||||
|
.and_then(|response| response.get("error"))
|
||||||
|
.and_then(|error| error.get("message"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
value
|
||||||
|
.get("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(|| extract_stream_error_message_from_chunks(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_stream_error_message_from_chunks(value: &Value) -> Option<String> {
|
||||||
|
value
|
||||||
|
.get("chunks")
|
||||||
|
.and_then(Value::as_array)?
|
||||||
|
.iter()
|
||||||
|
.find_map(extract_explicit_error_message_from_json)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_generic_error_message_from_json(value: &Value) -> Option<String> {
|
fn extract_generic_error_message_from_json(value: &Value) -> Option<String> {
|
||||||
@@ -5000,6 +5052,7 @@ mod tests {
|
|||||||
..UsageRoutingSeed::default()
|
..UsageRoutingSeed::default()
|
||||||
},
|
},
|
||||||
status_code: 200,
|
status_code: 200,
|
||||||
|
terminal_error_message: None,
|
||||||
response_time_ms: Some(123),
|
response_time_ms: Some(123),
|
||||||
first_byte_time_ms: Some(45),
|
first_byte_time_ms: Some(45),
|
||||||
request_headers: Some(json!({
|
request_headers: Some(json!({
|
||||||
|
|||||||
@@ -418,6 +418,25 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergePositiveDurationMs(
|
||||||
|
existingValue: number | null | undefined,
|
||||||
|
nextValue: number | null | undefined
|
||||||
|
): number | null | undefined {
|
||||||
|
const existingIsPositive = typeof existingValue === 'number' && Number.isFinite(existingValue) && existingValue > 0
|
||||||
|
const nextIsPositive = typeof nextValue === 'number' && Number.isFinite(nextValue) && nextValue > 0
|
||||||
|
|
||||||
|
if (existingIsPositive && nextIsPositive) {
|
||||||
|
return Math.max(existingValue, nextValue)
|
||||||
|
}
|
||||||
|
if (existingIsPositive) {
|
||||||
|
return existingValue
|
||||||
|
}
|
||||||
|
if (nextIsPositive) {
|
||||||
|
return nextValue
|
||||||
|
}
|
||||||
|
return existingValue ?? nextValue
|
||||||
|
}
|
||||||
|
|
||||||
function mergeRecordStatus(
|
function mergeRecordStatus(
|
||||||
current: UsageRecord[],
|
current: UsageRecord[],
|
||||||
next: UsageRecord[]
|
next: UsageRecord[]
|
||||||
@@ -513,8 +532,8 @@ export function useUsageData(options: UseUsageDataOptions) {
|
|||||||
cache_read_input_tokens: existing.cache_read_input_tokens ?? record.cache_read_input_tokens,
|
cache_read_input_tokens: existing.cache_read_input_tokens ?? record.cache_read_input_tokens,
|
||||||
cost: existing.cost || record.cost,
|
cost: existing.cost || record.cost,
|
||||||
actual_cost: existing.actual_cost ?? record.actual_cost,
|
actual_cost: existing.actual_cost ?? record.actual_cost,
|
||||||
response_time_ms: existing.response_time_ms ?? record.response_time_ms,
|
response_time_ms: mergePositiveDurationMs(existing.response_time_ms, record.response_time_ms),
|
||||||
first_byte_time_ms: existing.first_byte_time_ms ?? record.first_byte_time_ms,
|
first_byte_time_ms: mergePositiveDurationMs(existing.first_byte_time_ms, record.first_byte_time_ms),
|
||||||
is_stream: upstreamIsStream,
|
is_stream: upstreamIsStream,
|
||||||
upstream_is_stream: upstreamIsStream,
|
upstream_is_stream: upstreamIsStream,
|
||||||
client_requested_stream: clientRequestedStream,
|
client_requested_stream: clientRequestedStream,
|
||||||
|
|||||||
Reference in New Issue
Block a user