mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
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:
@@ -788,7 +788,6 @@ impl ClaudeClientEmitter {
|
||||
)?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn merge_claude_usage(mut current: CanonicalUsage, next: CanonicalUsage) -> CanonicalUsage {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user