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

@@ -788,7 +788,6 @@ impl ClaudeClientEmitter {
)?);
Ok(out)
}
}
fn merge_claude_usage(mut current: CanonicalUsage, next: CanonicalUsage) -> CanonicalUsage {

View File

@@ -110,6 +110,19 @@ impl OpenAIChatProviderState {
let mut out = Vec::new();
let Some(chunk_choices) = chunk_object.get("choices").and_then(Value::as_array) else {
if let Some(usage) = Self::finish_usage(chunk_object.get("usage")) {
self.ensure_started(report_context, &mut out);
let (id, model) = self.identity(report_context);
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::Finish {
finish_reason: self.pending_finish_reason.take(),
usage: Some(usage),
},
});
self.finished = true;
}
return Ok(out);
};
if chunk_choices.is_empty() {
@@ -1838,6 +1851,104 @@ mod tests {
assert_eq!(usage.cache_read_tokens, 19_840);
}
#[test]
fn openai_chat_provider_state_accepts_usage_only_terminal_chunk() {
let mut state = OpenAIChatProviderState::default();
let report_context = json!({});
let _ = state
.push_line(
&report_context,
data_line(json!({
"id": "chatcmpl_123",
"object": "chat.completion.chunk",
"model": "gpt-5.4",
"choices": [{
"index": 0,
"delta": {},
"finish_reason": "stop",
}],
})),
)
.expect("finish chunk should parse");
let frames = state
.push_line(
&report_context,
data_line(json!({
"usage": {
"input_tokens": 26,
"input_tokens_details": {
"cached_tokens": 0,
},
"output_tokens": 144,
"output_tokens_details": {
"reasoning_tokens": 10,
},
"total_tokens": 170,
},
})),
)
.expect("usage-only chunk should parse");
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::Finish {
finish_reason: Some(ref reason),
usage: Some(CanonicalUsage {
input_tokens: 26,
output_tokens: 144,
cache_read_tokens: 0,
..
}),
} if reason == "stop"
)));
}
#[test]
fn openai_cli_provider_state_extracts_response_completed_usage() {
let mut state = OpenAICliProviderState::default();
let report_context = json!({});
let frames = state
.push_line(
&report_context,
data_line(json!({
"type": "response.completed",
"response": {
"id": "resp_063494bbd780be940169eb8191c4ec8191916347b2080805ee",
"object": "response",
"model": "gpt-5.5",
"status": "completed",
"output": [],
"usage": {
"input_tokens": 26,
"input_tokens_details": {
"cached_tokens": 0,
},
"output_tokens": 137,
"output_tokens_details": {
"reasoning_tokens": 0,
},
"total_tokens": 163,
},
},
"sequence_number": 139,
})),
)
.expect("completed event should parse");
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::Finish {
usage: Some(CanonicalUsage {
input_tokens: 26,
output_tokens: 137,
cache_read_tokens: 0,
..
}),
..
}
)));
}
#[test]
fn openai_cli_client_emitter_emits_doc_like_text_events() {
let mut emitter = OpenAICliClientEmitter::default();

View File

@@ -238,12 +238,15 @@ enum ClientStreamEmitter {
}
fn provider_api_format_for_context(report_context: &Value) -> String {
report_context
.get("provider_api_format")
.and_then(Value::as_str)
string_context_field(report_context, "provider_stream_event_api_format")
.or_else(|| string_context_field(report_context, "provider_stream_api_format"))
.or_else(|| string_context_field(report_context, "provider_api_format"))
.unwrap_or_default()
.trim()
.to_ascii_lowercase()
}
fn string_context_field(report_context: &Value, key: &str) -> Option<String> {
let value = report_context.get(key)?.as_str()?.trim();
(!value.is_empty()).then(|| value.to_ascii_lowercase())
}
fn client_api_format_for_context(report_context: &Value) -> String {
@@ -317,12 +320,7 @@ impl ClientStreamEmitter {
fn build_client_error_body_for_line(report_context: &Value, line: &[u8]) -> Option<Value> {
let value = decode_json_data_line(line)?;
let provider_api_format = report_context
.get("provider_api_format")
.and_then(Value::as_str)
.unwrap_or_default()
.trim()
.to_ascii_lowercase();
let provider_api_format = provider_api_format_for_context(report_context);
let client_api_format = report_context
.get("client_api_format")
.and_then(Value::as_str)
@@ -801,4 +799,78 @@ mod tests {
assert_eq!(usage.cache_creation_tokens, 42_262);
assert_eq!(usage.cache_read_tokens, 0);
}
#[test]
fn terminal_observer_uses_explicit_provider_stream_event_api_format() {
let mut report_context = report_context("openai:chat", "openai:cli");
report_context["provider_stream_event_api_format"] = json!("openai:cli");
let mut observer = StreamingStandardTerminalObserver::default();
observer
.push_line(
&report_context,
data_line(json!({
"type": "response.completed",
"response": {
"id": "resp_codex_123",
"object": "response",
"model": "gpt-5.5",
"status": "completed",
"output": [],
"usage": {
"input_tokens": 26,
"input_tokens_details": {
"cached_tokens": 0,
},
"output_tokens": 137,
"output_tokens_details": {
"reasoning_tokens": 0,
},
"total_tokens": 163,
},
},
"sequence_number": 139,
})),
)
.expect("response.completed should parse");
let summary = observer
.latest_summary()
.cloned()
.expect("summary should exist");
let usage = summary
.standardized_usage
.expect("standardized usage should exist");
assert_eq!(usage.input_tokens, 26);
assert_eq!(usage.output_tokens, 137);
assert_eq!(usage.cache_read_tokens, 0);
}
#[test]
fn terminal_observer_does_not_infer_provider_stream_event_api_format() {
let report_context = report_context("openai:chat", "openai:cli");
let mut observer = StreamingStandardTerminalObserver::default();
observer
.push_line(
&report_context,
data_line(json!({
"type": "response.completed",
"response": {
"usage": {
"input_tokens": 26,
"output_tokens": 137,
"total_tokens": 163,
},
},
})),
)
.expect("line should be ignored by explicitly selected chat parser");
assert!(
observer.latest_summary().is_none(),
"provider stream parser selection must come from report context, not event sniffing"
);
}
}