mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-10 05:00:19 +08:00
Merge remote-tracking branch 'origin/pr/547'
This commit is contained in:
@@ -18,7 +18,10 @@ use context::{report_context_is_locally_actionable, resolve_locally_actionable_r
|
||||
use aether_usage_runtime::{
|
||||
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,
|
||||
stream_report_represents_failure, sync_report_represents_failure,
|
||||
stream_report_missing_terminal_event, stream_report_represents_failure,
|
||||
sync_report_represents_failure, STREAM_MISSING_TERMINAL_EVENT_CATEGORY,
|
||||
STREAM_MISSING_TERMINAL_EVENT_MESSAGE, STREAM_TERMINAL_ERROR_CATEGORY,
|
||||
STREAM_TERMINAL_ERROR_MESSAGE,
|
||||
};
|
||||
pub(crate) use aether_usage_runtime::{GatewayStreamReportRequest, GatewaySyncReportRequest};
|
||||
|
||||
@@ -257,6 +260,7 @@ async fn handle_local_stream_report(state: &AppState, payload: &GatewayStreamRep
|
||||
.as_ref()
|
||||
.and_then(|telemetry| telemetry.elapsed_ms);
|
||||
let failed = stream_report_represents_failure(payload);
|
||||
let missing_terminal_event = stream_report_missing_terminal_event(payload);
|
||||
record_report_request_candidate_status(
|
||||
state,
|
||||
payload.report_context.as_ref(),
|
||||
@@ -270,8 +274,10 @@ async fn handle_local_stream_report(state: &AppState, payload: &GatewayStreamRep
|
||||
error_type: failed.then(|| {
|
||||
if payload.status_code >= 400 {
|
||||
"stream_http_error".to_string()
|
||||
} else if missing_terminal_event {
|
||||
STREAM_MISSING_TERMINAL_EVENT_CATEGORY.to_string()
|
||||
} else {
|
||||
"stream_terminal_error".to_string()
|
||||
STREAM_TERMINAL_ERROR_CATEGORY.to_string()
|
||||
}
|
||||
}),
|
||||
error_message: failed.then(|| {
|
||||
@@ -280,7 +286,11 @@ async fn handle_local_stream_report(state: &AppState, payload: &GatewayStreamRep
|
||||
.as_ref()
|
||||
.and_then(|summary| summary.parser_error.clone())
|
||||
.unwrap_or_else(|| {
|
||||
"execution runtime stream ended with a terminal error".to_string()
|
||||
if missing_terminal_event {
|
||||
STREAM_MISSING_TERMINAL_EVENT_MESSAGE.to_string()
|
||||
} else {
|
||||
STREAM_TERMINAL_ERROR_MESSAGE.to_string()
|
||||
}
|
||||
})
|
||||
}),
|
||||
latency_ms,
|
||||
@@ -310,9 +320,11 @@ mod tests {
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
ProviderCatalogReadRepository, StoredProviderCatalogKey, StoredProviderCatalogProvider,
|
||||
};
|
||||
use aether_data_contracts::repository::usage::UsageBodyCaptureState;
|
||||
use aether_data_contracts::repository::video_tasks::{
|
||||
UpsertVideoTask, VideoTaskStatus, VideoTaskWriteRepository,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
@@ -757,6 +769,64 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_openai_responses_stream_report_marks_missing_terminal_event_as_failed() {
|
||||
let repository = Arc::new(InMemoryRequestCandidateRepository::seed(vec![
|
||||
sample_request_candidate(
|
||||
"cand-reporting-stream-missing-terminal-1",
|
||||
"req-reporting-stream-missing-terminal-1",
|
||||
),
|
||||
]));
|
||||
let state = build_test_state(Arc::clone(&repository));
|
||||
let provider_sse = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\"}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n"
|
||||
);
|
||||
|
||||
submit_stream_report(
|
||||
&state,
|
||||
GatewayStreamReportRequest {
|
||||
trace_id: "trace-reporting-stream-missing-terminal-1".to_string(),
|
||||
report_kind: "openai_responses_stream_success".to_string(),
|
||||
report_context: Some(json!({
|
||||
"request_id": "req-reporting-stream-missing-terminal-1",
|
||||
"client_api_format": "openai:responses",
|
||||
"provider_api_format": "openai:responses"
|
||||
})),
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
provider_body_base64: Some(
|
||||
base64::engine::general_purpose::STANDARD.encode(provider_sse.as_bytes()),
|
||||
),
|
||||
provider_body_state: Some(UsageBodyCaptureState::Inline),
|
||||
client_body_base64: None,
|
||||
client_body_state: None,
|
||||
terminal_summary: None,
|
||||
telemetry: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("stream report should stay local");
|
||||
|
||||
let stored = repository
|
||||
.list_by_request_id("req-reporting-stream-missing-terminal-1")
|
||||
.await
|
||||
.expect("request candidates should list");
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].status, RequestCandidateStatus::Failed);
|
||||
assert_eq!(stored[0].status_code, Some(200));
|
||||
assert_eq!(
|
||||
stored[0].error_type.as_deref(),
|
||||
Some("stream_missing_terminal_event")
|
||||
);
|
||||
assert_eq!(
|
||||
stored[0].error_message.as_deref(),
|
||||
Some("execution runtime stream ended before provider terminal event")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submit_sync_report_updates_codex_quota_from_response_headers() {
|
||||
crate::orchestration::clear_local_report_effect_caches_for_tests();
|
||||
|
||||
@@ -551,6 +551,101 @@ mod tests {
|
||||
assert_eq!(converted["stream"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_openai_chat_to_claude_normalizes_multiturn_tool_history() {
|
||||
let request = json!({
|
||||
"model": "deepseek-v4-flash",
|
||||
"messages": [
|
||||
{"role": "system", "content": "Be precise."},
|
||||
{"role": "user", "content": "check two things"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "weather-1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "arguments": "{\"city\":\"NYC\"}"}
|
||||
},
|
||||
{
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "arguments": "{\"q\":\"db\"}"}
|
||||
}
|
||||
]
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "weather-1", "content": ""},
|
||||
{"role": "tool", "tool_call_id": "call_2", "content": [{"type": "text", "text": "rows=1"}]},
|
||||
{"role": "user", "content": "now answer"}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "get_weather", "description": "Get weather", "parameters": null}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup",
|
||||
"description": "Lookup data",
|
||||
"parameters": {"properties": {"q": {"type": "string"}}}
|
||||
}
|
||||
}
|
||||
],
|
||||
"parallel_tool_calls": true,
|
||||
"max_tokens": 128,
|
||||
"stream": true
|
||||
});
|
||||
|
||||
let converted = build_standard_request_body(
|
||||
&request,
|
||||
"openai:chat",
|
||||
"claude-sonnet-4-5",
|
||||
"custom",
|
||||
"claude:messages",
|
||||
"/v1/chat/completions",
|
||||
true,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("openai chat tool history should build as claude messages");
|
||||
|
||||
assert_eq!(converted["model"], "claude-sonnet-4-5");
|
||||
assert_eq!(converted["system"], "Be precise.");
|
||||
assert_eq!(converted["messages"][0]["role"], "user");
|
||||
assert_eq!(converted["messages"][1]["role"], "assistant");
|
||||
assert_eq!(converted["messages"][1]["content"][0]["type"], "tool_use");
|
||||
assert_eq!(
|
||||
converted["messages"][1]["content"][0]["id"],
|
||||
"toolu_weather-1"
|
||||
);
|
||||
assert_eq!(converted["messages"][1]["content"][1]["id"], "call_2");
|
||||
assert_eq!(converted["messages"][2]["role"], "user");
|
||||
assert_eq!(
|
||||
converted["messages"][2]["content"][0]["type"],
|
||||
"tool_result"
|
||||
);
|
||||
assert_eq!(
|
||||
converted["messages"][2]["content"][0]["tool_use_id"],
|
||||
"toolu_weather-1"
|
||||
);
|
||||
assert_eq!(converted["messages"][2]["content"][0]["content"], "(empty)");
|
||||
assert_eq!(
|
||||
converted["messages"][2]["content"][1]["tool_use_id"],
|
||||
"call_2"
|
||||
);
|
||||
assert_eq!(converted["messages"][2]["content"][1]["content"], "rows=1");
|
||||
assert_eq!(converted["messages"][2]["content"][2]["type"], "text");
|
||||
assert_eq!(converted["messages"][2]["content"][2]["text"], "now answer");
|
||||
assert_eq!(converted["tools"][0]["input_schema"]["type"], "object");
|
||||
assert_eq!(
|
||||
converted["tools"][0]["input_schema"]["properties"],
|
||||
json!({})
|
||||
);
|
||||
assert_eq!(converted["tools"][1]["input_schema"]["type"], "object");
|
||||
assert_eq!(converted["stream"], true);
|
||||
}
|
||||
|
||||
fn codex_default_body_rules() -> Value {
|
||||
json!([
|
||||
{"action":"drop","path":"max_output_tokens"},
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(crate) const OPENAI_RESPONSES_EXTENSION_NAMESPACE: &str = "openai_responses"
|
||||
pub(crate) const OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE: &str = "openai_cli";
|
||||
const AETHER_EXTENSION_NAMESPACE: &str = "aether";
|
||||
const CLAUDE_TOOL_RESULT_SOURCE_MARKER: &str = "claude_tool_result";
|
||||
const OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER: &str = "openai_chat_tool_result";
|
||||
const OPENAI_CHAT_TOOL_ERROR_PREFIX: &str = "[tool error]";
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -725,7 +726,7 @@ pub(crate) fn openai_role_to_canonical(role: &str) -> CanonicalRole {
|
||||
"assistant" => CanonicalRole::Assistant,
|
||||
"system" => CanonicalRole::System,
|
||||
"developer" => CanonicalRole::Developer,
|
||||
"tool" => CanonicalRole::Tool,
|
||||
"tool" | "function" => CanonicalRole::Tool,
|
||||
_ => CanonicalRole::Unknown,
|
||||
}
|
||||
}
|
||||
@@ -1329,10 +1330,12 @@ pub(crate) fn openai_message_content_blocks(
|
||||
blocks.splice(0..0, reasoning_blocks);
|
||||
}
|
||||
}
|
||||
let mut saw_tool_calls = false;
|
||||
if let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) {
|
||||
for tool_call in tool_calls {
|
||||
let tool_call = tool_call.as_object()?;
|
||||
let function = tool_call.get("function").and_then(Value::as_object)?;
|
||||
saw_tool_calls = true;
|
||||
blocks.push(CanonicalContentBlock::ToolUse {
|
||||
id: tool_call
|
||||
.get("id")
|
||||
@@ -1349,12 +1352,38 @@ pub(crate) fn openai_message_content_blocks(
|
||||
});
|
||||
}
|
||||
}
|
||||
if role == CanonicalRole::Assistant && !saw_tool_calls {
|
||||
if let Some(function_call) = message.get("function_call").and_then(Value::as_object) {
|
||||
let name = function_call
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
blocks.push(CanonicalContentBlock::ToolUse {
|
||||
id: message
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or(name.as_str())
|
||||
.to_string(),
|
||||
name,
|
||||
input: parse_jsonish_value(function_call.get("arguments")),
|
||||
extensions: openai_extensions(message, &["role", "content", "function_call"]),
|
||||
});
|
||||
}
|
||||
}
|
||||
if role == CanonicalRole::Tool {
|
||||
let text = openai_content_text(message.get("content"));
|
||||
let mut extensions = openai_extensions(message, &["role", "content", "tool_call_id"]);
|
||||
extensions.insert(
|
||||
AETHER_EXTENSION_NAMESPACE.to_string(),
|
||||
json!({ "source": OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER }),
|
||||
);
|
||||
blocks.push(CanonicalContentBlock::ToolResult {
|
||||
tool_use_id: message
|
||||
.get("tool_call_id")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| message.get("name").and_then(Value::as_str))
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
name: None,
|
||||
@@ -1374,7 +1403,7 @@ pub(crate) fn openai_message_content_blocks(
|
||||
text
|
||||
}),
|
||||
is_error: false,
|
||||
extensions: openai_extensions(message, &["role", "content", "tool_call_id"]),
|
||||
extensions,
|
||||
});
|
||||
}
|
||||
Some(blocks)
|
||||
@@ -3798,7 +3827,10 @@ pub(crate) fn canonical_block_to_claude(
|
||||
} => {
|
||||
let mut out = Map::new();
|
||||
out.insert("type".to_string(), Value::String("tool_use".to_string()));
|
||||
out.insert("id".to_string(), Value::String(id.clone()));
|
||||
out.insert(
|
||||
"id".to_string(),
|
||||
Value::String(claude_compatible_tool_use_id(id)),
|
||||
);
|
||||
out.insert("name".to_string(), Value::String(name.clone()));
|
||||
out.insert("input".to_string(), input.clone());
|
||||
out.extend(namespace_extension_object(extensions, "claude", &out));
|
||||
@@ -3816,7 +3848,7 @@ pub(crate) fn canonical_block_to_claude(
|
||||
out.insert("type".to_string(), Value::String("tool_result".to_string()));
|
||||
out.insert(
|
||||
"tool_use_id".to_string(),
|
||||
Value::String(tool_use_id.clone()),
|
||||
Value::String(claude_compatible_tool_use_id(tool_use_id)),
|
||||
);
|
||||
out.insert(
|
||||
"content".to_string(),
|
||||
@@ -3824,6 +3856,7 @@ pub(crate) fn canonical_block_to_claude(
|
||||
output.as_ref(),
|
||||
content_text.as_deref(),
|
||||
role,
|
||||
extensions,
|
||||
),
|
||||
);
|
||||
out.insert("is_error".to_string(), Value::Bool(*is_error));
|
||||
@@ -3838,6 +3871,7 @@ fn canonical_tool_result_content_to_claude(
|
||||
output: Option<&Value>,
|
||||
content_text: Option<&str>,
|
||||
role: &CanonicalRole,
|
||||
extensions: &BTreeMap<String, Value>,
|
||||
) -> Value {
|
||||
if matches!(role, CanonicalRole::Assistant) {
|
||||
return output
|
||||
@@ -3845,15 +3879,54 @@ fn canonical_tool_result_content_to_claude(
|
||||
.unwrap_or_else(|| Value::String(content_text.unwrap_or_default().to_string()));
|
||||
}
|
||||
|
||||
if is_openai_chat_tool_result(extensions) {
|
||||
let text = content_text
|
||||
.map(ToOwned::to_owned)
|
||||
.or_else(|| output.map(openai_responses_tool_output_text))
|
||||
.unwrap_or_default();
|
||||
return Value::String(non_empty_tool_result_text(&text));
|
||||
}
|
||||
|
||||
match output {
|
||||
Some(Value::String(text)) => Value::String(text.clone()),
|
||||
Some(Value::String(text)) => Value::String(non_empty_tool_result_text(text)),
|
||||
Some(Value::Array(parts)) if claude_tool_result_content_blocks_are_wire_safe(parts) => {
|
||||
Value::Array(parts.clone())
|
||||
}
|
||||
Some(Value::Null) => Value::String(non_empty_tool_result_text("")),
|
||||
Some(value) => serde_json::to_string(value)
|
||||
.map(Value::String)
|
||||
.unwrap_or_else(|_| Value::String(content_text.unwrap_or_default().to_string())),
|
||||
None => Value::String(content_text.unwrap_or_default().to_string()),
|
||||
.map(|text| Value::String(non_empty_tool_result_text(&text)))
|
||||
.unwrap_or_else(|_| {
|
||||
Value::String(non_empty_tool_result_text(content_text.unwrap_or_default()))
|
||||
}),
|
||||
None => Value::String(non_empty_tool_result_text(content_text.unwrap_or_default())),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_openai_chat_tool_result(extensions: &BTreeMap<String, Value>) -> bool {
|
||||
extensions
|
||||
.get(AETHER_EXTENSION_NAMESPACE)
|
||||
.and_then(|value| value.get("source"))
|
||||
.and_then(Value::as_str)
|
||||
== Some(OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER)
|
||||
}
|
||||
|
||||
fn non_empty_tool_result_text(text: &str) -> String {
|
||||
if text.trim().is_empty() {
|
||||
"(empty)".to_string()
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_compatible_tool_use_id(id: &str) -> String {
|
||||
let trimmed = id.trim();
|
||||
if trimmed.is_empty() {
|
||||
return "toolu_".to_string();
|
||||
}
|
||||
if trimmed.starts_with("toolu_") || trimmed.starts_with("call_") {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("toolu_{trimmed}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3986,7 +4059,7 @@ pub(crate) fn canonical_tools_to_claude(canonical: &CanonicalRequest) -> Vec<Val
|
||||
}
|
||||
out.insert(
|
||||
"input_schema".to_string(),
|
||||
tool.parameters.clone().unwrap_or_else(|| json!({})),
|
||||
claude_input_schema_from_tool_parameters(tool.parameters.as_ref()),
|
||||
);
|
||||
out.extend(namespace_extension_object(&tool.extensions, "claude", &out));
|
||||
Value::Object(out)
|
||||
@@ -4004,6 +4077,23 @@ pub(crate) fn canonical_tools_to_claude(canonical: &CanonicalRequest) -> Vec<Val
|
||||
tools
|
||||
}
|
||||
|
||||
fn claude_input_schema_from_tool_parameters(parameters: Option<&Value>) -> Value {
|
||||
match parameters {
|
||||
Some(Value::Object(schema)) => {
|
||||
let mut schema = schema.clone();
|
||||
schema
|
||||
.entry("type".to_string())
|
||||
.or_insert_with(|| Value::String("object".to_string()));
|
||||
schema
|
||||
.entry("properties".to_string())
|
||||
.or_insert_with(|| json!({}));
|
||||
Value::Object(schema)
|
||||
}
|
||||
Some(Value::Null) | None => json!({"type": "object", "properties": {}}),
|
||||
Some(_) => json!({"type": "object", "properties": {}}),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn canonical_tool_choice_to_claude(
|
||||
choice: Option<&CanonicalToolChoice>,
|
||||
parallel_tool_calls: Option<bool>,
|
||||
|
||||
@@ -261,10 +261,9 @@ impl RequestCandidateTrace {
|
||||
pub fn derive_request_candidate_final_status(
|
||||
candidates: &[StoredRequestCandidate],
|
||||
) -> RequestCandidateFinalStatus {
|
||||
let has_success = candidates.iter().any(|candidate| {
|
||||
candidate.status == RequestCandidateStatus::Success
|
||||
|| matches!(candidate.status_code, Some(status_code) if (200..300).contains(&status_code))
|
||||
});
|
||||
let has_success = candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.status == RequestCandidateStatus::Success);
|
||||
if has_success {
|
||||
return RequestCandidateFinalStatus::Success;
|
||||
}
|
||||
@@ -297,6 +296,13 @@ pub fn derive_request_candidate_final_status(
|
||||
return RequestCandidateFinalStatus::Pending;
|
||||
}
|
||||
|
||||
let has_legacy_success_status_code = candidates
|
||||
.iter()
|
||||
.any(|candidate| matches!(candidate.status_code, Some(status_code) if (200..300).contains(&status_code)));
|
||||
if has_legacy_success_status_code {
|
||||
return RequestCandidateFinalStatus::Success;
|
||||
}
|
||||
|
||||
RequestCandidateFinalStatus::Failed
|
||||
}
|
||||
|
||||
@@ -536,3 +542,72 @@ impl<T> RequestCandidateRepository for T where
|
||||
T: RequestCandidateReadRepository + RequestCandidateWriteRepository + Send + Sync
|
||||
{
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
derive_request_candidate_final_status, RequestCandidateFinalStatus, RequestCandidateStatus,
|
||||
StoredRequestCandidate,
|
||||
};
|
||||
|
||||
fn candidate(
|
||||
id: &str,
|
||||
status: RequestCandidateStatus,
|
||||
status_code: Option<i32>,
|
||||
) -> StoredRequestCandidate {
|
||||
StoredRequestCandidate::new(
|
||||
id.to_string(),
|
||||
"req-1".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
status,
|
||||
None,
|
||||
false,
|
||||
status_code,
|
||||
None,
|
||||
None,
|
||||
Some(100),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
1_700_000_000_000,
|
||||
Some(1_700_000_000_000),
|
||||
Some(1_700_000_000_100),
|
||||
)
|
||||
.expect("candidate should build")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_candidate_with_http_200_stays_final_failed() {
|
||||
let candidates = vec![candidate(
|
||||
"cand-1",
|
||||
RequestCandidateStatus::Failed,
|
||||
Some(200),
|
||||
)];
|
||||
|
||||
assert_eq!(
|
||||
derive_request_candidate_final_status(&candidates),
|
||||
RequestCandidateFinalStatus::Failed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_success_candidate_still_wins_after_failed_attempt() {
|
||||
let candidates = vec![
|
||||
candidate("cand-1", RequestCandidateStatus::Failed, Some(503)),
|
||||
candidate("cand-2", RequestCandidateStatus::Success, Some(200)),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
derive_request_candidate_final_status(&candidates),
|
||||
RequestCandidateFinalStatus::Success
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,13 @@ pub use report::{
|
||||
infer_internal_finalize_signature, is_local_ai_stream_report_kind,
|
||||
is_local_ai_sync_report_kind, normalize_gemini_file_name, report_request_id,
|
||||
resolve_internal_finalize_route, should_handle_local_stream_report,
|
||||
should_handle_local_sync_report, stream_report_represents_failure,
|
||||
sync_report_represents_failure, GatewayStreamReportRequest, GatewaySyncReportRequest,
|
||||
GeminiFileMappingEntry, InternalFinalizeRoute, GEMINI_FILE_MAPPING_TTL_SECONDS,
|
||||
should_handle_local_sync_report, stream_capture_terminal_state,
|
||||
stream_report_missing_terminal_event, stream_report_represents_failure,
|
||||
stream_report_requires_observed_terminal_event, sync_report_represents_failure,
|
||||
GatewayStreamReportRequest, GatewaySyncReportRequest, GeminiFileMappingEntry,
|
||||
InternalFinalizeRoute, StreamCapturedTerminalState, GEMINI_FILE_MAPPING_TTL_SECONDS,
|
||||
STREAM_MISSING_TERMINAL_EVENT_CATEGORY, STREAM_MISSING_TERMINAL_EVENT_MESSAGE,
|
||||
STREAM_TERMINAL_ERROR_CATEGORY, STREAM_TERMINAL_ERROR_MESSAGE,
|
||||
};
|
||||
pub use report_context::{
|
||||
build_locally_actionable_report_context_from_request_candidate,
|
||||
|
||||
@@ -8,6 +8,19 @@ use serde_json::Value;
|
||||
|
||||
pub const GEMINI_FILE_MAPPING_TTL_SECONDS: u64 = 60 * 60 * 48;
|
||||
const GEMINI_FILE_MAPPING_CACHE_PREFIX: &str = "gemini_files:key";
|
||||
pub const STREAM_MISSING_TERMINAL_EVENT_CATEGORY: &str = "stream_missing_terminal_event";
|
||||
pub const STREAM_TERMINAL_ERROR_CATEGORY: &str = "stream_terminal_error";
|
||||
pub const STREAM_MISSING_TERMINAL_EVENT_MESSAGE: &str =
|
||||
"execution runtime stream ended before provider terminal event";
|
||||
pub const STREAM_TERMINAL_ERROR_MESSAGE: &str =
|
||||
"execution runtime stream ended with a terminal error";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StreamCapturedTerminalState {
|
||||
Completed,
|
||||
Failed,
|
||||
Missing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct GatewaySyncReportRequest {
|
||||
@@ -292,22 +305,342 @@ pub fn sync_report_represents_failure(
|
||||
.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
|
||||
fn stream_terminal_summary_missing_observed_finish(
|
||||
summary: &ExecutionStreamTerminalSummary,
|
||||
requires_observed_terminal_event: bool,
|
||||
) -> bool {
|
||||
!summary.observed_finish
|
||||
&& (requires_observed_terminal_event
|
||||
|| !summary
|
||||
.standardized_usage
|
||||
.as_ref()
|
||||
.is_some_and(aether_contracts::StandardizedUsage::has_token_signal))
|
||||
}
|
||||
|
||||
fn stream_terminal_summary_represents_failure(
|
||||
summary: &ExecutionStreamTerminalSummary,
|
||||
requires_observed_terminal_event: bool,
|
||||
) -> bool {
|
||||
summary.parser_error.is_some()
|
||||
|| stream_terminal_summary_missing_observed_finish(
|
||||
summary,
|
||||
requires_observed_terminal_event,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn stream_report_represents_failure(payload: &GatewayStreamReportRequest) -> bool {
|
||||
let requires_observed_terminal_event = stream_report_requires_observed_terminal_event(
|
||||
payload.report_kind.as_str(),
|
||||
payload.report_context.as_ref(),
|
||||
);
|
||||
payload.status_code >= 400
|
||||
|| payload.report_kind.contains("error")
|
||||
|| payload
|
||||
.terminal_summary
|
||||
.as_ref()
|
||||
.is_some_and(stream_terminal_summary_represents_failure)
|
||||
|| payload.terminal_summary.as_ref().is_some_and(|summary| {
|
||||
stream_terminal_summary_represents_failure(summary, requires_observed_terminal_event)
|
||||
})
|
||||
|| stream_report_captured_terminal_failure(payload)
|
||||
|| stream_report_missing_terminal_event(payload)
|
||||
}
|
||||
|
||||
pub fn stream_report_missing_terminal_event(payload: &GatewayStreamReportRequest) -> bool {
|
||||
let requires_observed_terminal_event = stream_report_requires_observed_terminal_event(
|
||||
payload.report_kind.as_str(),
|
||||
payload.report_context.as_ref(),
|
||||
);
|
||||
if !requires_observed_terminal_event {
|
||||
return false;
|
||||
}
|
||||
|
||||
payload
|
||||
.terminal_summary
|
||||
.as_ref()
|
||||
.is_some_and(|summary| stream_terminal_summary_missing_observed_finish(summary, true))
|
||||
|| matches!(
|
||||
stream_report_captured_terminal_state(payload),
|
||||
Some(StreamCapturedTerminalState::Missing)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn stream_report_captured_terminal_failure(payload: &GatewayStreamReportRequest) -> bool {
|
||||
matches!(
|
||||
stream_report_captured_terminal_state(payload),
|
||||
Some(StreamCapturedTerminalState::Failed)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn stream_report_requires_observed_terminal_event(
|
||||
report_kind: &str,
|
||||
report_context: Option<&Value>,
|
||||
) -> bool {
|
||||
let report_kind = report_kind.trim().to_ascii_lowercase();
|
||||
if report_kind.starts_with("openai_responses_")
|
||||
|| report_kind.starts_with("openai_compact_")
|
||||
|| report_kind.starts_with("openai_cli_")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let Some(context) = report_context.and_then(Value::as_object) else {
|
||||
return false;
|
||||
};
|
||||
[
|
||||
"provider_stream_event_api_format",
|
||||
"provider_stream_api_format",
|
||||
"provider_api_format",
|
||||
"client_api_format",
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|field| context.get(field).and_then(Value::as_str))
|
||||
.any(is_openai_responses_family_format_alias)
|
||||
}
|
||||
|
||||
fn is_openai_responses_family_format_alias(value: &str) -> bool {
|
||||
let normalized = value.trim().to_ascii_lowercase().replace('_', ":");
|
||||
aether_ai_formats::is_openai_responses_family_format(normalized.as_str())
|
||||
}
|
||||
|
||||
fn stream_report_captured_terminal_state(
|
||||
payload: &GatewayStreamReportRequest,
|
||||
) -> Option<StreamCapturedTerminalState> {
|
||||
let provider_state = stream_report_provider_capture_requires_terminal_event(payload)
|
||||
.then(|| stream_capture_terminal_state_from_base64(payload.provider_body_base64.as_deref()))
|
||||
.flatten();
|
||||
let client_state = stream_report_client_capture_requires_terminal_event(payload)
|
||||
.then(|| stream_capture_terminal_state_from_base64(payload.client_body_base64.as_deref()))
|
||||
.flatten();
|
||||
combine_stream_terminal_states(provider_state, client_state)
|
||||
}
|
||||
|
||||
fn stream_report_provider_capture_requires_terminal_event(
|
||||
payload: &GatewayStreamReportRequest,
|
||||
) -> bool {
|
||||
let context = payload.report_context.as_ref();
|
||||
stream_report_context_has_openai_responses_format(
|
||||
context,
|
||||
&[
|
||||
"provider_stream_event_api_format",
|
||||
"provider_stream_api_format",
|
||||
"provider_api_format",
|
||||
],
|
||||
) || (stream_report_kind_requires_observed_terminal_event(payload.report_kind.as_str())
|
||||
&& !stream_report_context_has_any_format(context))
|
||||
}
|
||||
|
||||
fn stream_report_client_capture_requires_terminal_event(
|
||||
payload: &GatewayStreamReportRequest,
|
||||
) -> bool {
|
||||
let context = payload.report_context.as_ref();
|
||||
stream_report_context_has_openai_responses_format(context, &["client_api_format"])
|
||||
|| (stream_report_kind_requires_observed_terminal_event(payload.report_kind.as_str())
|
||||
&& !stream_report_context_has_any_format(context))
|
||||
}
|
||||
|
||||
fn stream_report_context_has_openai_responses_format(
|
||||
report_context: Option<&Value>,
|
||||
fields: &[&str],
|
||||
) -> bool {
|
||||
fields
|
||||
.iter()
|
||||
.filter_map(|field| {
|
||||
report_context
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|context| context.get(*field))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.any(is_openai_responses_family_format_alias)
|
||||
}
|
||||
|
||||
fn stream_report_context_has_any_format(report_context: Option<&Value>) -> bool {
|
||||
[
|
||||
"provider_stream_event_api_format",
|
||||
"provider_stream_api_format",
|
||||
"provider_api_format",
|
||||
"client_api_format",
|
||||
]
|
||||
.into_iter()
|
||||
.any(|field| {
|
||||
report_context
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|context| context.get(field))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn stream_report_kind_requires_observed_terminal_event(report_kind: &str) -> bool {
|
||||
let report_kind = report_kind.trim().to_ascii_lowercase();
|
||||
report_kind.starts_with("openai_responses_")
|
||||
|| report_kind.starts_with("openai_compact_")
|
||||
|| report_kind.starts_with("openai_cli_")
|
||||
}
|
||||
|
||||
fn stream_capture_terminal_state_from_base64(
|
||||
body_base64: Option<&str>,
|
||||
) -> Option<StreamCapturedTerminalState> {
|
||||
let body_base64 = body_base64?;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(body_base64)
|
||||
.ok()?;
|
||||
if let Ok(value) = serde_json::from_slice::<Value>(&bytes) {
|
||||
return stream_capture_terminal_state(&value);
|
||||
}
|
||||
let text = String::from_utf8(bytes).ok()?;
|
||||
stream_capture_terminal_state_from_sse_text(text.as_str())
|
||||
}
|
||||
|
||||
pub fn stream_capture_terminal_state(value: &Value) -> Option<StreamCapturedTerminalState> {
|
||||
if let Some(chunks) = value.get("chunks").and_then(Value::as_array) {
|
||||
let mut state = None;
|
||||
for chunk in chunks {
|
||||
state = combine_stream_terminal_states(state, openai_response_terminal_state(chunk));
|
||||
}
|
||||
return state.or_else(|| {
|
||||
stream_capture_looks_like_stream(value).then_some(StreamCapturedTerminalState::Missing)
|
||||
});
|
||||
}
|
||||
|
||||
openai_response_terminal_state(value)
|
||||
}
|
||||
|
||||
fn stream_capture_looks_like_stream(value: &Value) -> bool {
|
||||
value
|
||||
.get("metadata")
|
||||
.and_then(|metadata| metadata.get("stream"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
|| value.get("chunks").and_then(Value::as_array).is_some()
|
||||
}
|
||||
|
||||
fn openai_response_terminal_state(value: &Value) -> Option<StreamCapturedTerminalState> {
|
||||
if value.get("error").is_some_and(|error| !error.is_null()) {
|
||||
return Some(StreamCapturedTerminalState::Failed);
|
||||
}
|
||||
|
||||
let event_type = value
|
||||
.get("type")
|
||||
.or_else(|| value.get("event"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
match event_type {
|
||||
"response.completed" => return Some(StreamCapturedTerminalState::Completed),
|
||||
"response.failed" | "response.incomplete" | "error" => {
|
||||
return Some(StreamCapturedTerminalState::Failed);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let response = value.get("response");
|
||||
if response
|
||||
.and_then(|response| response.get("error"))
|
||||
.is_some_and(|error| !error.is_null())
|
||||
|| response
|
||||
.and_then(|response| response.get("incomplete_details"))
|
||||
.is_some_and(|details| !details.is_null())
|
||||
{
|
||||
return Some(StreamCapturedTerminalState::Failed);
|
||||
}
|
||||
|
||||
match response
|
||||
.and_then(|response| response.get("status"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
{
|
||||
Some("completed") => Some(StreamCapturedTerminalState::Completed),
|
||||
Some("failed" | "incomplete" | "cancelled") => Some(StreamCapturedTerminalState::Failed),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_stream_terminal_states(
|
||||
current: Option<StreamCapturedTerminalState>,
|
||||
next: Option<StreamCapturedTerminalState>,
|
||||
) -> Option<StreamCapturedTerminalState> {
|
||||
match (current, next) {
|
||||
(Some(StreamCapturedTerminalState::Failed), _)
|
||||
| (_, Some(StreamCapturedTerminalState::Failed)) => {
|
||||
Some(StreamCapturedTerminalState::Failed)
|
||||
}
|
||||
(Some(StreamCapturedTerminalState::Missing), _)
|
||||
| (_, Some(StreamCapturedTerminalState::Missing)) => {
|
||||
Some(StreamCapturedTerminalState::Missing)
|
||||
}
|
||||
(Some(StreamCapturedTerminalState::Completed), _)
|
||||
| (_, Some(StreamCapturedTerminalState::Completed)) => {
|
||||
Some(StreamCapturedTerminalState::Completed)
|
||||
}
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_capture_terminal_state_from_sse_text(text: &str) -> Option<StreamCapturedTerminalState> {
|
||||
let mut state = None;
|
||||
let mut saw_stream_payload = false;
|
||||
for_each_sse_payload(text, |payload| {
|
||||
saw_stream_payload = true;
|
||||
if payload == "[DONE]" {
|
||||
return;
|
||||
}
|
||||
if let Ok(value) = serde_json::from_str::<Value>(payload) {
|
||||
state = combine_stream_terminal_states(state, openai_response_terminal_state(&value));
|
||||
}
|
||||
});
|
||||
state.or_else(|| saw_stream_payload.then_some(StreamCapturedTerminalState::Missing))
|
||||
}
|
||||
|
||||
fn flush_sse_payload<F>(payload: &mut String, has_payload: &mut bool, on_payload: &mut F)
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
if !*has_payload {
|
||||
return;
|
||||
}
|
||||
on_payload(payload);
|
||||
payload.clear();
|
||||
*has_payload = false;
|
||||
}
|
||||
|
||||
fn for_each_sse_payload<F>(text: &str, mut on_payload: F)
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
let mut payload = String::new();
|
||||
let mut has_payload = false;
|
||||
let bytes = text.as_bytes();
|
||||
let mut line_start = 0usize;
|
||||
let mut cursor = 0usize;
|
||||
|
||||
while cursor <= bytes.len() {
|
||||
if cursor < bytes.len() && bytes[cursor] != b'\n' && bytes[cursor] != b'\r' {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let line = text[line_start..cursor].trim();
|
||||
if line.is_empty() {
|
||||
flush_sse_payload(&mut payload, &mut has_payload, &mut on_payload);
|
||||
} else if let Some(data) = line.strip_prefix("data:").map(str::trim) {
|
||||
if !data.is_empty() {
|
||||
if has_payload {
|
||||
payload.push('\n');
|
||||
}
|
||||
payload.push_str(data);
|
||||
has_payload = true;
|
||||
}
|
||||
}
|
||||
|
||||
if cursor == bytes.len() {
|
||||
break;
|
||||
}
|
||||
if bytes[cursor] == b'\r' && bytes.get(cursor + 1) == Some(&b'\n') {
|
||||
cursor += 2;
|
||||
} else {
|
||||
cursor += 1;
|
||||
}
|
||||
line_start = cursor;
|
||||
}
|
||||
flush_sse_payload(&mut payload, &mut has_payload, &mut on_payload);
|
||||
}
|
||||
|
||||
pub fn should_handle_local_sync_report(
|
||||
@@ -392,6 +725,7 @@ mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aether_contracts::ExecutionStreamTerminalSummary;
|
||||
use aether_data_contracts::repository::usage::UsageBodyCaptureState;
|
||||
use base64::Engine as _;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -529,6 +863,73 @@ mod tests {
|
||||
assert!(stream_report_represents_failure(&payload));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_openai_responses_stream_missing_terminal_from_captured_sse() {
|
||||
let provider_sse = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\"}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n"
|
||||
);
|
||||
let mut payload = sample_stream_report("openai_responses_stream_success", 200);
|
||||
payload.report_context = Some(json!({
|
||||
"client_api_format": "openai:responses",
|
||||
"provider_api_format": "openai:responses"
|
||||
}));
|
||||
payload.provider_body_base64 =
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(provider_sse.as_bytes()));
|
||||
payload.provider_body_state = Some(UsageBodyCaptureState::Inline);
|
||||
|
||||
assert!(stream_report_represents_failure(&payload));
|
||||
assert!(super::stream_report_missing_terminal_event(&payload));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_openai_responses_stream_completed_from_captured_sse() {
|
||||
let provider_sse = concat!(
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n"
|
||||
);
|
||||
let mut payload = sample_stream_report("openai_responses_stream_success", 200);
|
||||
payload.report_context = Some(json!({
|
||||
"client_api_format": "openai:responses",
|
||||
"provider_api_format": "openai:responses"
|
||||
}));
|
||||
payload.provider_body_base64 =
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(provider_sse.as_bytes()));
|
||||
payload.provider_body_state = Some(UsageBodyCaptureState::Inline);
|
||||
|
||||
assert!(!stream_report_represents_failure(&payload));
|
||||
assert!(!super::stream_report_missing_terminal_event(&payload));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_completed_openai_responses_provider_with_rewritten_openai_chat_client_stream() {
|
||||
let provider_sse = concat!(
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\"}}\n\n"
|
||||
);
|
||||
let client_sse = concat!(
|
||||
"data: {\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
let mut payload = sample_stream_report("openai_chat_stream_success", 200);
|
||||
payload.report_context = Some(json!({
|
||||
"client_api_format": "openai:chat",
|
||||
"provider_api_format": "openai:responses",
|
||||
"provider_stream_event_api_format": "openai:responses"
|
||||
}));
|
||||
payload.provider_body_base64 =
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(provider_sse.as_bytes()));
|
||||
payload.provider_body_state = Some(UsageBodyCaptureState::Inline);
|
||||
payload.client_body_base64 =
|
||||
Some(base64::engine::general_purpose::STANDARD.encode(client_sse.as_bytes()));
|
||||
payload.client_body_state = Some(UsageBodyCaptureState::Inline);
|
||||
|
||||
assert!(!stream_report_represents_failure(&payload));
|
||||
assert!(!super::stream_report_missing_terminal_event(&payload));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_internal_finalize_signature_from_context_or_report_kind() {
|
||||
let from_context = sample_sync_report_with_context(
|
||||
|
||||
@@ -18,8 +18,11 @@ use crate::request_metadata::{
|
||||
sanitize_usage_request_metadata_ref,
|
||||
};
|
||||
use crate::{
|
||||
map_usage_from_response, GatewayStreamReportRequest, GatewaySyncReportRequest,
|
||||
StandardizedUsage, UsageEvent, UsageEventData, UsageEventType,
|
||||
map_usage_from_response, stream_capture_terminal_state, GatewayStreamReportRequest,
|
||||
GatewaySyncReportRequest, StandardizedUsage, StreamCapturedTerminalState, UsageEvent,
|
||||
UsageEventData, UsageEventType, STREAM_MISSING_TERMINAL_EVENT_CATEGORY,
|
||||
STREAM_MISSING_TERMINAL_EVENT_MESSAGE, STREAM_TERMINAL_ERROR_CATEGORY,
|
||||
STREAM_TERMINAL_ERROR_MESSAGE,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -926,10 +929,30 @@ pub fn build_stream_terminal_usage_seed(
|
||||
map_usage_from_response(response, context_seed.provider_contract.as_str())
|
||||
})
|
||||
});
|
||||
let captured_terminal_state = captured_stream_terminal_state(
|
||||
report_kind.as_str(),
|
||||
context_seed.client_contract.as_str(),
|
||||
context_seed.provider_contract.as_str(),
|
||||
provider_response_full.as_ref(),
|
||||
client_response.as_ref(),
|
||||
);
|
||||
let requires_observed_terminal_event = stream_usage_requires_observed_terminal_event(
|
||||
report_kind.as_str(),
|
||||
context_seed.client_contract.as_str(),
|
||||
context_seed.provider_contract.as_str(),
|
||||
);
|
||||
let observed_stream_finish = observed_stream_finish.or_else(|| {
|
||||
captured_terminal_state.map(|state| state != StreamCapturedTerminalState::Missing)
|
||||
});
|
||||
let missing_observed_finish = matches!(observed_stream_finish, Some(false))
|
||||
&& !standardized_usage
|
||||
.as_ref()
|
||||
.is_some_and(StandardizedUsage::has_token_signal);
|
||||
&& (requires_observed_terminal_event
|
||||
|| !standardized_usage
|
||||
.as_ref()
|
||||
.is_some_and(StandardizedUsage::has_token_signal));
|
||||
let captured_terminal_failure = matches!(
|
||||
captured_terminal_state,
|
||||
Some(StreamCapturedTerminalState::Failed)
|
||||
);
|
||||
let terminal_error_message = terminal_error_message
|
||||
.or_else(|| {
|
||||
provider_response_full
|
||||
@@ -940,17 +963,17 @@ pub fn build_stream_terminal_usage_seed(
|
||||
client_response
|
||||
.as_ref()
|
||||
.and_then(extract_explicit_error_message_from_json)
|
||||
});
|
||||
})
|
||||
.or_else(|| captured_terminal_failure.then(|| STREAM_TERMINAL_ERROR_MESSAGE.to_string()));
|
||||
let terminal_failure_category = if terminal_error_message.is_some() {
|
||||
Some("stream_terminal_error".to_string())
|
||||
Some(STREAM_TERMINAL_ERROR_CATEGORY.to_string())
|
||||
} else if missing_observed_finish {
|
||||
Some("stream_missing_terminal_event".to_string())
|
||||
Some(STREAM_MISSING_TERMINAL_EVENT_CATEGORY.to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let terminal_error_message = terminal_error_message.or_else(|| {
|
||||
missing_observed_finish
|
||||
.then(|| "execution runtime stream ended before provider terminal event".to_string())
|
||||
missing_observed_finish.then(|| STREAM_MISSING_TERMINAL_EVENT_MESSAGE.to_string())
|
||||
});
|
||||
if client_response.is_none() {
|
||||
if let (Some(message), Some(category)) = (
|
||||
@@ -1055,6 +1078,76 @@ fn infer_stream_terminal_state(
|
||||
}
|
||||
}
|
||||
|
||||
fn captured_stream_terminal_state(
|
||||
report_kind: &str,
|
||||
client_contract: &str,
|
||||
provider_contract: &str,
|
||||
provider_response: Option<&Value>,
|
||||
client_response: Option<&Value>,
|
||||
) -> Option<StreamCapturedTerminalState> {
|
||||
let report_kind_requires_terminal_event =
|
||||
stream_report_kind_requires_observed_terminal_event(report_kind);
|
||||
let provider_contract_requires_terminal_event =
|
||||
is_openai_responses_family_format_alias(provider_contract);
|
||||
let client_contract_requires_terminal_event =
|
||||
is_openai_responses_family_format_alias(client_contract);
|
||||
let fallback_requires_terminal_event = report_kind_requires_terminal_event
|
||||
&& !provider_contract_requires_terminal_event
|
||||
&& !client_contract_requires_terminal_event;
|
||||
|
||||
combine_stream_capture_terminal_states(
|
||||
(provider_contract_requires_terminal_event || fallback_requires_terminal_event)
|
||||
.then(|| provider_response.and_then(stream_capture_terminal_state))
|
||||
.flatten(),
|
||||
(client_contract_requires_terminal_event || fallback_requires_terminal_event)
|
||||
.then(|| client_response.and_then(stream_capture_terminal_state))
|
||||
.flatten(),
|
||||
)
|
||||
}
|
||||
|
||||
fn combine_stream_capture_terminal_states(
|
||||
current: Option<StreamCapturedTerminalState>,
|
||||
next: Option<StreamCapturedTerminalState>,
|
||||
) -> Option<StreamCapturedTerminalState> {
|
||||
match (current, next) {
|
||||
(Some(StreamCapturedTerminalState::Failed), _)
|
||||
| (_, Some(StreamCapturedTerminalState::Failed)) => {
|
||||
Some(StreamCapturedTerminalState::Failed)
|
||||
}
|
||||
(Some(StreamCapturedTerminalState::Missing), _)
|
||||
| (_, Some(StreamCapturedTerminalState::Missing)) => {
|
||||
Some(StreamCapturedTerminalState::Missing)
|
||||
}
|
||||
(Some(StreamCapturedTerminalState::Completed), _)
|
||||
| (_, Some(StreamCapturedTerminalState::Completed)) => {
|
||||
Some(StreamCapturedTerminalState::Completed)
|
||||
}
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_report_kind_requires_observed_terminal_event(report_kind: &str) -> bool {
|
||||
let report_kind = report_kind.trim().to_ascii_lowercase();
|
||||
report_kind.starts_with("openai_responses_")
|
||||
|| report_kind.starts_with("openai_compact_")
|
||||
|| report_kind.starts_with("openai_cli_")
|
||||
}
|
||||
|
||||
fn stream_usage_requires_observed_terminal_event(
|
||||
report_kind: &str,
|
||||
client_contract: &str,
|
||||
provider_contract: &str,
|
||||
) -> bool {
|
||||
stream_report_kind_requires_observed_terminal_event(report_kind)
|
||||
|| is_openai_responses_family_format_alias(client_contract)
|
||||
|| is_openai_responses_family_format_alias(provider_contract)
|
||||
}
|
||||
|
||||
fn is_openai_responses_family_format_alias(value: &str) -> bool {
|
||||
let normalized = value.trim().to_ascii_lowercase().replace('_', ":");
|
||||
aether_ai_formats::is_openai_responses_family_format(normalized.as_str())
|
||||
}
|
||||
|
||||
fn resolve_has_format_conversion(
|
||||
context: Option<&Map<String, Value>>,
|
||||
client_contract: &str,
|
||||
@@ -3945,6 +4038,77 @@ mod tests {
|
||||
assert_eq!(event.data.output_tokens, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_terminal_usage_marks_missing_captured_openai_responses_terminal_as_failed() {
|
||||
let plan = ExecutionPlan {
|
||||
request_id: "req-stream-missing-captured-finish-1".to_string(),
|
||||
candidate_id: Some("cand-stream-missing-captured-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 provider_sse = concat!(
|
||||
"event: response.created\n",
|
||||
"data: {\"type\":\"response.created\"}\n\n",
|
||||
"event: response.output_text.delta\n",
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n"
|
||||
);
|
||||
let payload = GatewayStreamReportRequest {
|
||||
trace_id: "trace-stream-missing-captured-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::from([(
|
||||
"content-type".to_string(),
|
||||
"text/event-stream".to_string(),
|
||||
)]),
|
||||
provider_body_base64: Some(
|
||||
base64::engine::general_purpose::STANDARD.encode(provider_sse.as_bytes()),
|
||||
),
|
||||
provider_body_state: Some(UsageBodyCaptureState::Inline),
|
||||
client_body_base64: None,
|
||||
client_body_state: Some(UsageBodyCaptureState::None),
|
||||
terminal_summary: 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.event_type, UsageEventType::Failed);
|
||||
assert_eq!(event.data.status_code, Some(200));
|
||||
assert_eq!(
|
||||
event.data.error_category.as_deref(),
|
||||
Some("stream_missing_terminal_event")
|
||||
);
|
||||
assert_eq!(
|
||||
event.data.error_message.as_deref(),
|
||||
Some("execution runtime stream ended before provider terminal event")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_terminal_usage_marks_http_200_response_failed_as_stream_terminal_error() {
|
||||
let plan = ExecutionPlan {
|
||||
|
||||
Reference in New Issue
Block a user