fix(usage): 流式终端 usage 以更完整值为准,Codex CLI 显式选择 response 解析器

- 新增 StandardizedUsage::signal_score/is_more_complete_than/choose_more_complete,流式合并与终端落库均按信号完整度择优
- OpenAI Chat/CLI 解析器支持仅 usage 的终结 chunk 与 response.completed usage
- Codex provider 注入 provider_stream_event_api_format=openai:cli,解析器选择改由 report_context 显式决定
- usage_mapper 扩展嵌套 response/message/item 兼容 Claude message_start/message_delta 及 Gemini stream chunks
- usage SQL upsert 在终态(completed/failed/cancelled)时按 GREATEST 写入 token/费用镜像列
This commit is contained in:
fawney19
2026-04-25 00:57:01 +08:00
parent 2c6209277f
commit f30b1f3f6b
14 changed files with 656 additions and 72 deletions

View File

@@ -229,9 +229,11 @@ fn resolve_usage_value<'a>(
}
}
if let Some(nested) = response.get("response") {
if let Some(usage) = resolve_usage_value(nested, family) {
return Some(usage);
for nested_key in ["response", "message", "item"] {
if let Some(nested) = response.get(nested_key) {
if let Some(usage) = resolve_usage_value(nested, family) {
return Some(usage);
}
}
}
@@ -418,6 +420,69 @@ mod tests {
assert_eq!(usage.cache_read_tokens, 1);
}
#[test]
fn maps_claude_usage_from_stream_chunks() {
let usage = map_usage_from_response(
&serde_json::json!({
"chunks": [
{
"type": "message_start",
"message": {
"usage": {
"input_tokens": 5,
"cache_creation_input_tokens": 59_573,
"cache_read_input_tokens": 0,
"output_tokens": 0
}
}
},
{
"type": "message_delta",
"usage": {
"input_tokens": 5,
"cache_creation_input_tokens": 59_573,
"cache_read_input_tokens": 0,
"output_tokens": 162
}
}
]
}),
"claude:chat",
);
assert_eq!(usage.input_tokens, 5);
assert_eq!(usage.output_tokens, 162);
assert_eq!(usage.cache_creation_tokens, 59_573);
assert_eq!(usage.cache_read_tokens, 0);
}
#[test]
fn maps_claude_usage_from_message_start_chunk() {
let usage = map_usage_from_response(
&serde_json::json!({
"chunks": [
{
"type": "message_start",
"message": {
"usage": {
"input_tokens": 5,
"cache_creation_input_tokens": 59_573,
"cache_read_input_tokens": 0,
"output_tokens": 0
}
}
}
]
}),
"claude:chat",
);
assert_eq!(usage.input_tokens, 5);
assert_eq!(usage.output_tokens, 0);
assert_eq!(usage.cache_creation_tokens, 59_573);
assert_eq!(usage.cache_read_tokens, 0);
}
#[test]
fn maps_claude_usage_with_large_cache_read_tokens_without_subtracting_input() {
let usage = map_usage(
@@ -507,4 +572,35 @@ mod tests {
assert_eq!(usage.output_tokens, 6);
assert_eq!(usage.cache_read_tokens, 2);
}
#[test]
fn maps_gemini_usage_from_stream_chunks() {
let usage = map_usage_from_response(
&serde_json::json!({
"chunks": [
{
"candidates": [
{
"content": {
"parts": [{ "text": "hello" }]
}
}
]
},
{
"usageMetadata": {
"promptTokenCount": 14,
"candidatesTokenCount": 6,
"cachedContentTokenCount": 2
}
}
]
}),
"gemini:chat",
);
assert_eq!(usage.input_tokens, 14);
assert_eq!(usage.output_tokens, 6);
assert_eq!(usage.cache_read_tokens, 2);
}
}

View File

@@ -532,18 +532,13 @@ fn build_terminal_usage_event_from_seed_impl(
let endpoint_kind = infer_endpoint_kind(&client_contract).map(ToOwned::to_owned);
let provider_api_family = infer_api_family(&provider_contract).map(ToOwned::to_owned);
let provider_endpoint_kind = infer_endpoint_kind(&provider_contract).map(ToOwned::to_owned);
let derived_standardized_usage = standardized_usage
let derived_standardized_usage = provider_response
.as_ref()
.is_none()
.then(|| {
provider_response
.as_ref()
.filter(|response_body| response_body.is_object())
.map(|response_body| {
map_usage_from_response(response_body, provider_contract.as_str())
})
})
.flatten();
.filter(|response_body| response_body.is_object())
.map(|response_body| map_usage_from_response(response_body, provider_contract.as_str()))
.filter(StandardizedUsage::has_token_signal);
let standardized_usage =
StandardizedUsage::choose_more_complete(standardized_usage, derived_standardized_usage);
let request_metadata = if trusted_request_metadata {
merge_usage_request_metadata_owned(request_metadata, audit_payload)
} else {
@@ -608,9 +603,6 @@ fn build_terminal_usage_event_from_seed_impl(
apply_standardized_usage_seed(usage, &mut data);
}
if let Some(usage) = derived_standardized_usage.as_ref() {
apply_standardized_usage_seed(usage, &mut data);
}
if data.total_tokens.is_none() {
if let Some(tokens) = data
.response_body
@@ -2787,6 +2779,111 @@ mod tests {
assert!(event.data.client_response_body.is_none());
}
#[test]
fn stream_terminal_usage_prefers_more_complete_provider_chunks_usage() {
let plan = ExecutionPlan {
request_id: "req-stream-provider-chunks-usage-1".to_string(),
candidate_id: Some("cand-stream-provider-chunks-usage-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:cli".to_string(),
provider_api_format: "openai:cli".to_string(),
model_name: Some("gpt-5.5".to_string()),
proxy: None,
tls_profile: None,
timeouts: None,
};
let mut partial_summary_usage = StandardizedUsage::new();
partial_summary_usage.output_tokens = 148;
let provider_body = json!({
"chunks": [
{
"type": "response.created",
"response": {
"id": "resp_123",
"object": "response",
"model": "gpt-5.5",
"status": "in_progress",
"usage": null
}
},
{
"type": "response.completed",
"response": {
"id": "resp_123",
"object": "response",
"model": "gpt-5.5",
"status": "completed",
"usage": {
"input_tokens": 26,
"input_tokens_details": {
"cached_tokens": 0
},
"output_tokens": 148,
"output_tokens_details": {
"reasoning_tokens": 10
},
"total_tokens": 174
}
},
"sequence_number": 141
}
],
"metadata": {
"stream": true,
"total_chunks": 142,
"stored_chunks": 142
}
});
let payload = GatewayStreamReportRequest {
trace_id: "trace-stream-provider-chunks-usage-1".to_string(),
report_kind: "openai_cli_stream_success".to_string(),
report_context: Some(json!({
"client_api_format": "openai:cli",
"provider_api_format": "openai:cli",
})),
status_code: 200,
headers: BTreeMap::new(),
provider_body_base64: Some(
base64::engine::general_purpose::STANDARD.encode(provider_body.to_string()),
),
provider_body_state: Some(UsageBodyCaptureState::Inline),
client_body_base64: None,
client_body_state: Some(UsageBodyCaptureState::None),
terminal_summary: Some(ExecutionStreamTerminalSummary {
standardized_usage: Some(partial_summary_usage),
finish_reason: None,
response_id: Some("resp_123".to_string()),
model: Some("gpt-5.5".to_string()),
observed_finish: true,
parser_error: None,
}),
telemetry: None,
};
let event =
build_stream_terminal_usage_event(&plan, payload.report_context.as_ref(), &payload)
.expect("usage event should build");
assert_eq!(event.data.input_tokens, Some(26));
assert_eq!(event.data.output_tokens, Some(148));
assert_eq!(event.data.total_tokens, Some(174));
assert_eq!(event.data.cache_read_input_tokens, None);
}
#[test]
fn builds_stream_terminal_usage_from_sse_chunks_and_extracts_usage() {
let plan = ExecutionPlan {