From 23e0af7b1223b8ba420157aa2527b79a4fd248ff Mon Sep 17 00:00:00 2001 From: wanzhao-ysy Date: Sat, 12 Sep 2026 22:44:14 +0800 Subject: [PATCH 01/72] fix(frontend): show Anti Gravity v1internal endpoint path --- .../components/__tests__/endpoint-default-paths.spec.ts | 8 ++++++++ .../providers/components/endpoint-default-paths.ts | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/frontend/src/features/providers/components/__tests__/endpoint-default-paths.spec.ts b/frontend/src/features/providers/components/__tests__/endpoint-default-paths.spec.ts index b95b0894d..e3685af34 100644 --- a/frontend/src/features/providers/components/__tests__/endpoint-default-paths.spec.ts +++ b/frontend/src/features/providers/components/__tests__/endpoint-default-paths.spec.ts @@ -69,6 +69,14 @@ describe('endpoint default paths', () => { apiFormats, })).toBe('/v1internal:{action}') }) + it('uses Anti Gravity v1internal paths for fixed Anti Gravity endpoints', () => { + expect(getDefaultEndpointPath({ + apiFormat: 'gemini:generate_content', + providerType: 'antigravity', + baseUrl: 'https://daily-cloudcode-pa.googleapis.com', + apiFormats, + })).toBe('/v1internal:{action}') + }) it('keeps Codex Responses root path without duplicating /v1', () => { expect(getDefaultEndpointPath({ diff --git a/frontend/src/features/providers/components/endpoint-default-paths.ts b/frontend/src/features/providers/components/endpoint-default-paths.ts index 44714e0c4..4ca44dbfa 100644 --- a/frontend/src/features/providers/components/endpoint-default-paths.ts +++ b/frontend/src/features/providers/components/endpoint-default-paths.ts @@ -182,6 +182,11 @@ export function getDefaultEndpointPath(params: { return '/v1internal:{action}' } } + if (providerType === 'antigravity') { + if (normalizedApiFormat === 'gemini:generate_content') { + return '/v1internal:{action}' + } + } if (providerType === 'vertex_ai') { if (normalizedApiFormat === 'gemini:generate_content') { return '/v1/projects/{project_id}/locations/{region}/publishers/google/models/{model}:{action}' From ea24d61910037dcf32f46a25cb454f75358a17da Mon Sep 17 00:00:00 2001 From: wanzhao-ysy Date: Sun, 13 Sep 2026 18:35:57 +0800 Subject: [PATCH 02/72] fix(antigravity): omit agent requestType from v1internal envelope --- .../standard/openai/chat/decision/request.rs | 2 +- .../src/tests/ai_execute/finalize_local.rs | 2 +- .../finalize_local_cli/cross_format.rs | 2 +- .../finalize_local_provider/gemini.rs | 2 +- .../stream_provider_gemini/local_cli.rs | 2 +- .../src/tests/ai_execute/sync/gemini/cli.rs | 2 +- .../transport/src/antigravity/request.rs | 25 ++++++++++++------- 7 files changed, 22 insertions(+), 15 deletions(-) diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs index 4bfa07dea..ce719f126 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/decision/request.rs @@ -2763,7 +2763,7 @@ mod tests { payload.provider_request_body["userAgent"], "vscode/1.X.X (Antigravity/4.3.0)" ); - assert_eq!(payload.provider_request_body["requestType"], "agent"); + assert!(payload.provider_request_body.get("requestType").is_none()); assert!(payload.provider_request_body.get("contents").is_none()); assert!(payload.provider_request_body["request"] .get("contents") diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs index 3bac90c5d..dd8c0350a 100644 --- a/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs +++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local.rs @@ -1972,7 +1972,7 @@ async fn gateway_executes_openai_chat_antigravity_cross_format_sync_via_local_fi seen_execution_runtime_request.user_agent, aether_provider_transport::antigravity::ANTIGRAVITY_REQUEST_USER_AGENT ); - assert_eq!(seen_execution_runtime_request.request_type, "agent"); + assert_eq!(seen_execution_runtime_request.request_type, ""); assert_eq!(seen_execution_runtime_request.contents_len, 1); assert!(!seen_execution_runtime_request.request_has_model); diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs index 0cedf3111..f199ee5f3 100644 --- a/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs +++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs @@ -1570,7 +1570,7 @@ async fn gateway_executes_openai_responses_antigravity_cross_format_upstream_str seen_remote_execution_runtime_request.user_agent, aether_provider_transport::antigravity::ANTIGRAVITY_REQUEST_USER_AGENT ); - assert_eq!(seen_remote_execution_runtime_request.request_type, "agent"); + assert_eq!(seen_remote_execution_runtime_request.request_type, ""); assert_eq!(seen_remote_execution_runtime_request.contents_len, 1); assert!(!seen_remote_execution_runtime_request.request_has_model); diff --git a/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs b/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs index 7c3575c2f..6a84c0e24 100644 --- a/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs +++ b/apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs @@ -2155,7 +2155,7 @@ async fn gateway_executes_antigravity_gemini_cli_sync_upstream_stream_via_local_ seen_remote_execution_runtime_request.user_agent, aether_provider_transport::antigravity::ANTIGRAVITY_REQUEST_USER_AGENT ); - assert_eq!(seen_remote_execution_runtime_request.request_type, "agent"); + assert_eq!(seen_remote_execution_runtime_request.request_type, ""); assert_eq!(seen_remote_execution_runtime_request.contents_len, 0); assert!((seen_remote_execution_runtime_request.exact_temperature - 0.2).abs() < f64::EPSILON); assert!(!seen_remote_execution_runtime_request.request_has_model); diff --git a/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs b/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs index 5c5d26cd0..603ba0d64 100644 --- a/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs +++ b/apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs @@ -2060,7 +2060,7 @@ async fn gateway_executes_antigravity_gemini_cli_stream_via_local_decision_gate_ seen_execution_runtime_request.user_agent, aether_provider_transport::antigravity::ANTIGRAVITY_REQUEST_USER_AGENT ); - assert_eq!(seen_execution_runtime_request.request_type, "agent"); + assert_eq!(seen_execution_runtime_request.request_type, ""); assert_eq!(seen_execution_runtime_request.contents_len, 0); assert!((seen_execution_runtime_request.exact_temperature - 0.2).abs() < f64::EPSILON); assert!(!seen_execution_runtime_request.request_has_model); diff --git a/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs b/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs index 8abd3a094..bf491eef1 100644 --- a/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs +++ b/apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs @@ -2374,7 +2374,7 @@ async fn gateway_executes_antigravity_gemini_cli_sync_via_local_decision_gate_af seen_execution_runtime_request.user_agent, aether_provider_transport::antigravity::ANTIGRAVITY_REQUEST_USER_AGENT ); - assert_eq!(seen_execution_runtime_request.request_type, "agent"); + assert_eq!(seen_execution_runtime_request.request_type, ""); assert_eq!(seen_execution_runtime_request.contents_len, 0); assert!((seen_execution_runtime_request.exact_temperature - 0.2).abs() < f64::EPSILON); assert!(!seen_execution_runtime_request.request_has_model); diff --git a/crates/aether-provider/transport/src/antigravity/request.rs b/crates/aether-provider/transport/src/antigravity/request.rs index b0ddc5b9b..c25a1cb97 100644 --- a/crates/aether-provider/transport/src/antigravity/request.rs +++ b/crates/aether-provider/transport/src/antigravity/request.rs @@ -83,17 +83,21 @@ pub fn build_antigravity_safe_v1internal_request( let request_id = non_empty_string_field(source, "requestId").unwrap_or(request_id); let user_agent = non_empty_string_field(source, "userAgent").unwrap_or(ANTIGRAVITY_REQUEST_USER_AGENT); - let request_type = - existing_v1internal_request_type(source).unwrap_or_else(|| request_type.as_str()); + let existing_request_type = existing_v1internal_request_type(source); - return AntigravityRequestEnvelopeSupport::Supported(serde_json::json!({ + let mut envelope = serde_json::json!({ "project": auth.project_id, "requestId": request_id, "request": Value::Object(inner_request), "model": model, "userAgent": user_agent, - "requestType": request_type, - })); + }); + if let Some(existing_request_type) = existing_request_type { + envelope["requestType"] = Value::String(existing_request_type.to_string()); + } else if request_type != AntigravityEnvelopeRequestType::Agent { + envelope["requestType"] = Value::String(request_type.as_str().to_string()); + } + return AntigravityRequestEnvelopeSupport::Supported(envelope); } let mut inner_request: Map = source.clone(); @@ -103,14 +107,17 @@ pub fn build_antigravity_safe_v1internal_request( normalize_antigravity_builtin_tool_names(&mut inner_request); normalize_antigravity_function_declaration_parameters(&mut inner_request); - AntigravityRequestEnvelopeSupport::Supported(serde_json::json!({ + let mut envelope = serde_json::json!({ "project": auth.project_id, "requestId": request_id, "request": Value::Object(inner_request), "model": model, "userAgent": ANTIGRAVITY_REQUEST_USER_AGENT, - "requestType": request_type.as_str(), - })) + }); + if request_type != AntigravityEnvelopeRequestType::Agent { + envelope["requestType"] = Value::String(request_type.as_str().to_string()); + } + AntigravityRequestEnvelopeSupport::Supported(envelope) } /// Antigravity's private v1internal Gemini surface still uses the legacy @@ -298,7 +305,7 @@ mod tests { assert_eq!(envelope["requestId"], "request-ant-agent-123"); assert_eq!(envelope["model"], "gemini-3.5-flash-low"); assert_eq!(envelope["userAgent"], ANTIGRAVITY_REQUEST_USER_AGENT); - assert_eq!(envelope["requestType"], "agent"); + assert!(envelope.get("requestType").is_none()); assert!(envelope["request"].get("model").is_none()); assert!(envelope["request"].get("safetySettings").is_none()); assert_eq!( From c5adcf0312b0941e631e5c7b41e2b3b1f0c82e1b Mon Sep 17 00:00:00 2001 From: Kayphoon <109347466+Kayphoon@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:51:52 +0000 Subject: [PATCH 03/72] fix(responses): map raw reasoning into content, keep summary for CLI OpenAI Responses treats reasoning.content as the raw chain-of-thought and summary as a skim view. Aether was dumping thinking into summary and leaving content null, which hid the thinking panel in desktop UIs. Put reasoning_content / equivalent text into reasoning_text content parts, and copy the same text into summary_text so CLI clients still work. Stream emitters now send both reasoning_text and summary events. --- .../src/execution_runtime/grok.rs | 19 +- .../src/formats/conversion/response.rs | 71 +++++- .../formats/src/formats/openai/chat/stream.rs | 240 +++++++++--------- .../src/formats/openai/responses/mod.rs | 84 +++++- .../src/formats/openai/responses/request.rs | 11 +- .../src/formats/openai/responses/response.rs | 75 +++++- .../shared/stream_core/format_matrix.rs | 4 + .../src/formats/shared/sync_products.rs | 42 ++- .../formats/src/protocol/canonical.rs | 67 ++--- 9 files changed, 431 insertions(+), 182 deletions(-) diff --git a/apps/aether-gateway/src/execution_runtime/grok.rs b/apps/aether-gateway/src/execution_runtime/grok.rs index a44e2d792..340a5a5f3 100644 --- a/apps/aether-gateway/src/execution_runtime/grok.rs +++ b/apps/aether-gateway/src/execution_runtime/grok.rs @@ -3198,13 +3198,18 @@ fn openai_responses_body( let response_id = format!("resp_{}", Uuid::new_v4()); let mut output = Vec::new(); if !collected.thinking.trim().is_empty() { + let thinking = collected.thinking.trim(); output.push(json!({ "id": openai_responses_synthetic_reasoning_item_id(&response_id, 0), "type": "reasoning", "status": "completed", "summary": [{ "type": "summary_text", - "text": collected.thinking.trim(), + "text": thinking, + }], + "content": [{ + "type": "reasoning_text", + "text": thinking, }], })); } @@ -4627,6 +4632,18 @@ mod tests { serde_json::json!(usage.reasoning_tokens) ); assert_eq!(body["output"][0]["type"], serde_json::json!("reasoning")); + assert_eq!( + body["output"][0]["content"][0]["type"], + serde_json::json!("reasoning_text") + ); + assert_eq!( + body["output"][0]["content"][0]["text"], + serde_json::json!("short reasoning") + ); + assert_eq!( + body["output"][0]["summary"][0]["text"], + serde_json::json!("short reasoning") + ); assert_eq!(body["output"][1]["type"], serde_json::json!("message")); assert!(body["output"][1]["id"] .as_str() diff --git a/crates/aether-ai/formats/src/formats/conversion/response.rs b/crates/aether-ai/formats/src/formats/conversion/response.rs index 646b99a9f..a10863e6b 100644 --- a/crates/aether-ai/formats/src/formats/conversion/response.rs +++ b/crates/aether-ai/formats/src/formats/conversion/response.rs @@ -9,7 +9,8 @@ use serde_json::{json, Value}; use crate::formats::{ context::FormatContext, openai::responses::{ - openai_responses_message_item_id, openai_responses_synthetic_reasoning_item_id, + openai_responses_message_item_id, openai_responses_reasoning_text_fields, + openai_responses_synthetic_reasoning_item_id, response::ensure_modern_openai_responses_response_fields, }, registry, @@ -205,14 +206,13 @@ pub fn build_openai_responses_response_with_content( if trimmed.is_empty() { continue; } + let (content, summary) = openai_responses_reasoning_text_fields(std::iter::once(trimmed)); output.push(json!({ "type": "reasoning", "id": openai_responses_synthetic_reasoning_item_id(response_id, index), "status": "completed", - "summary": [{ - "type": "summary_text", - "text": trimmed, - }] + "summary": summary, + "content": content, })); } if !content.is_empty() { @@ -289,6 +289,32 @@ mod tests { assert!(converted["completed_at"].as_i64().is_some()); } + #[test] + fn manual_responses_response_builder_puts_reasoning_in_content() { + let response = super::build_openai_responses_response_with_reasoning( + "resp_manual_reason", + "gpt-5", + "answer", + vec!["raw thinking".to_string()], + Vec::new(), + super::OpenAiResponsesResponseUsage { + prompt_tokens: 1, + output_tokens: 2, + total_tokens: 3, + }, + ); + + assert_eq!(response["output"][0]["type"], "reasoning"); + assert_eq!( + response["output"][0]["content"][0]["type"], + "reasoning_text" + ); + assert_eq!(response["output"][0]["content"][0]["text"], "raw thinking"); + assert_eq!(response["output"][0]["summary"][0]["type"], "summary_text"); + assert_eq!(response["output"][0]["summary"][0]["text"], "raw thinking"); + assert_eq!(response["output"][1]["content"][0]["text"], "answer"); + } + #[test] fn manual_responses_response_builder_emits_modern_fields() { let response = super::build_openai_responses_response( @@ -306,6 +332,41 @@ mod tests { assert!(response["completed_at"].as_i64().is_some()); } + #[test] + fn chat_reasoning_content_maps_to_responses_content_and_summary() { + let body = json!({ + "id": "chatcmpl-reason", + "object": "chat.completion", + "model": "deepseek-reasoner", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "reasoning_content": "compare the decimals", + "content": "9.80 is larger" + }, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3} + }); + + let converted = convert_openai_chat_response_to_openai_responses(&body, &json!({}), false) + .expect("responses response"); + let item = &converted["output"][0]; + + assert_eq!(item["type"], "reasoning"); + assert_eq!(item["content"][0]["type"], "reasoning_text"); + assert_eq!(item["content"][0]["text"], "compare the decimals"); + assert_eq!(item["summary"][0]["type"], "summary_text"); + assert_eq!(item["summary"][0]["text"], "compare the decimals"); + assert!(!item.get("content").unwrap().is_null()); + assert_eq!(converted["output"][1]["type"], "message"); + assert_eq!( + converted["output"][1]["content"][0]["text"], + "9.80 is larger" + ); + } + #[test] fn pairwise_response_helper_uses_report_context_model_fallback() { let body = json!({ diff --git a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs index a5a29524e..d71d545d8 100644 --- a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs +++ b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs @@ -6,7 +6,7 @@ use sha2::{Digest, Sha256}; use crate::formats::openai::namespace::NamespaceToolAliases; use crate::formats::openai::responses::{ encode_gemini_tool_signature_carrier_with_direction, openai_responses_message_item_id, - openai_responses_synthetic_reasoning_item_id, + openai_responses_reasoning_text_fields, openai_responses_synthetic_reasoning_item_id, response::{ ensure_modern_openai_responses_response_fields, openai_responses_current_timestamp, }, @@ -2621,6 +2621,107 @@ impl OpenAIResponsesClientEmitter { self.reasoning_summary_parts.len() } + fn reasoning_texts(&self) -> Vec { + if self.reasoning_summary_parts.is_empty() { + if self.reasoning.trim().is_empty() { + Vec::new() + } else { + vec![self.reasoning.clone()] + } + } else { + self.reasoning_summary_parts.clone() + } + } + + fn reasoning_item_value(&self) -> Value { + let (content, summary) = openai_responses_reasoning_text_fields(self.reasoning_texts()); + json!({ + "type": "reasoning", + "id": self.reasoning_item_id(), + "status": "completed", + "summary": summary, + "content": content, + }) + } + + fn encode_reasoning_text_delta( + &mut self, + text: &str, + ) -> Result, AiSurfaceFinalizeError> { + let item_id = self.reasoning_item_id(); + let output_index = self.reasoning_output_index.unwrap_or(0); + let part_index = self.current_reasoning_summary_index(); + let mut out = self.encode_response_event( + "response.reasoning_text.delta", + json!({ + "type": "response.reasoning_text.delta", + "response_id": self.response_id(), + "item_id": item_id.clone(), + "output_index": output_index, + "content_index": part_index, + "delta": text, + }), + )?; + out.extend(self.encode_response_event( + "response.reasoning_summary_text.delta", + json!({ + "type": "response.reasoning_summary_text.delta", + "response_id": self.response_id(), + "item_id": item_id, + "output_index": output_index, + "summary_index": part_index, + "delta": text, + }), + )?); + Ok(out) + } + + fn encode_reasoning_text_done_events( + &mut self, + item_id: &str, + output_index: usize, + part_index: usize, + part_text: &str, + ) -> Result, AiSurfaceFinalizeError> { + let mut out = self.encode_response_event( + "response.reasoning_text.done", + json!({ + "type": "response.reasoning_text.done", + "response_id": self.response_id(), + "item_id": item_id, + "output_index": output_index, + "content_index": part_index, + "text": part_text, + }), + )?; + out.extend(self.encode_response_event( + "response.reasoning_summary_text.done", + json!({ + "type": "response.reasoning_summary_text.done", + "response_id": self.response_id(), + "item_id": item_id, + "output_index": output_index, + "summary_index": part_index, + "text": part_text, + }), + )?); + out.extend(self.encode_response_event( + "response.reasoning_summary_part.done", + json!({ + "type": "response.reasoning_summary_part.done", + "response_id": self.response_id(), + "item_id": item_id, + "output_index": output_index, + "summary_index": part_index, + "part": { + "type": "summary_text", + "text": part_text, + } + }), + )?); + Ok(out) + } + fn ensure_message_output_index(&mut self) -> usize { if let Some(output_index) = self.message_output_index { return output_index; @@ -2676,6 +2777,7 @@ impl OpenAIResponsesClientEmitter { "type": "reasoning", "id": item_id.clone(), "summary": [], + "content": [], } }), )?); @@ -2812,66 +2914,23 @@ impl OpenAIResponsesClientEmitter { if self.reasoning_part_started { let summary_index = self.current_reasoning_summary_index(); let part_text = self.reasoning_part.clone(); - out.extend(self.encode_response_event( - "response.reasoning_summary_text.done", - json!({ - "type": "response.reasoning_summary_text.done", - "response_id": self.response_id(), - "item_id": item_id.clone(), - "output_index": output_index, - "summary_index": summary_index, - "text": part_text.as_str(), - }), - )?); - out.extend(self.encode_response_event( - "response.reasoning_summary_part.done", - json!({ - "type": "response.reasoning_summary_part.done", - "response_id": self.response_id(), - "item_id": item_id.clone(), - "output_index": output_index, - "summary_index": summary_index, - "part": { - "type": "summary_text", - "text": part_text.as_str(), - } - }), + out.extend(self.encode_reasoning_text_done_events( + &item_id, + output_index, + summary_index, + part_text.as_str(), )?); self.reasoning_summary_parts.push(part_text); self.reasoning_part.clear(); self.reasoning_part_started = false; } - let summary = if self.reasoning_summary_parts.is_empty() { - if self.reasoning.trim().is_empty() { - Vec::new() - } else { - vec![json!({ - "type": "summary_text", - "text": self.reasoning.as_str(), - })] - } - } else { - self.reasoning_summary_parts - .iter() - .map(|text| { - json!({ - "type": "summary_text", - "text": text, - }) - }) - .collect::>() - }; out.extend(self.encode_response_event( "response.output_item.done", json!({ "type": "response.output_item.done", "response_id": self.response_id(), "output_index": output_index, - "item": { - "type": "reasoning", - "id": item_id, - "summary": summary, - } + "item": self.reasoning_item_value(), }), )?); Ok(out) @@ -3035,35 +3094,10 @@ impl OpenAIResponsesClientEmitter { incomplete_reason: Option<&str>, ) -> Value { let mut ordered_output = Vec::new(); - let summary = if self.reasoning_summary_parts.is_empty() { - if self.reasoning.trim().is_empty() { - Vec::new() - } else { - vec![json!({ - "type": "summary_text", - "text": self.reasoning.as_str(), - })] - } - } else { - self.reasoning_summary_parts - .iter() - .map(|text| { - json!({ - "type": "summary_text", - "text": text, - }) - }) - .collect::>() - }; - if !summary.is_empty() { + if !self.reasoning_texts().is_empty() { ordered_output.push(( self.reasoning_output_index.unwrap_or(0), - json!({ - "type": "reasoning", - "id": self.reasoning_item_id(), - "status": "completed", - "summary": summary, - }), + self.reasoning_item_value(), )); } if self.text_item_started || !self.text.is_empty() { @@ -3297,17 +3331,7 @@ impl OpenAIResponsesClientEmitter { let mut out = self.ensure_reasoning_item_started()?; self.reasoning.push_str(&text); self.reasoning_part.push_str(&text); - out.extend(self.encode_response_event( - "response.reasoning_summary_text.delta", - json!({ - "type": "response.reasoning_summary_text.delta", - "response_id": self.response_id(), - "item_id": self.reasoning_item_id(), - "output_index": self.reasoning_output_index.unwrap_or(0), - "summary_index": self.current_reasoning_summary_index(), - "delta": text, - }), - )?); + out.extend(self.encode_reasoning_text_delta(&text)?); Ok(out) } CanonicalStreamEvent::ReasoningSummaryDone => { @@ -3320,32 +3344,12 @@ impl OpenAIResponsesClientEmitter { let item_id = self.reasoning_item_id(); let summary_index = self.current_reasoning_summary_index(); let part_text = self.reasoning_part.clone(); - let mut out = Vec::new(); - out.extend(self.encode_response_event( - "response.reasoning_summary_text.done", - json!({ - "type": "response.reasoning_summary_text.done", - "response_id": self.response_id(), - "item_id": item_id.clone(), - "output_index": output_index, - "summary_index": summary_index, - "text": part_text.as_str(), - }), - )?); - out.extend(self.encode_response_event( - "response.reasoning_summary_part.done", - json!({ - "type": "response.reasoning_summary_part.done", - "response_id": self.response_id(), - "item_id": item_id, - "output_index": output_index, - "summary_index": summary_index, - "part": { - "type": "summary_text", - "text": part_text.as_str(), - } - }), - )?); + let out = self.encode_reasoning_text_done_events( + &item_id, + output_index, + summary_index, + part_text.as_str(), + )?; self.reasoning_summary_parts.push(part_text); self.reasoning_part.clear(); self.reasoning_part_started = false; @@ -6352,13 +6356,19 @@ mod tests { let sse = String::from_utf8(bytes).expect("sse should be utf8"); assert!(sse.contains("event: response.reasoning_summary_part.added\n")); + assert!(sse.contains("event: response.reasoning_text.delta\n")); assert!(sse.contains("event: response.reasoning_summary_text.delta\n")); + assert!(sse.contains("event: response.reasoning_text.done\n")); assert!(sse.contains("event: response.reasoning_summary_text.done\n")); assert!(sse.contains("event: response.reasoning_summary_part.done\n")); + assert!(sse.contains("\"type\":\"reasoning_text\"")); let reasoning_item_id = openai_responses_synthetic_reasoning_item_id("resp_456", 0); assert!(sse.contains(&format!("\"item_id\":\"{reasoning_item_id}\""))); assert!(sse.contains("\"type\":\"reasoning\"")); - assert_eq!(response_sequence_numbers(&sse), (1..=9).collect::>()); + assert_eq!( + response_sequence_numbers(&sse), + (1..=11).collect::>() + ); } #[test] diff --git a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs index 36053f67e..e41661c23 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs @@ -1,5 +1,5 @@ use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _}; -use serde_json::Value; +use serde_json::{json, Map, Value}; pub mod codex; pub(crate) mod history; @@ -119,6 +119,58 @@ pub fn openai_responses_message_item_id(response_id: &str, output_index: usize) ) } +/// Builds Responses reasoning `content` / `summary` arrays from raw thinking text. +/// +/// OpenAI Responses semantics: +/// - `content` holds raw chain-of-thought as `reasoning_text` parts. Desktop UIs +/// (for example Codex) hide the thinking panel when `content` is null. +/// - `summary` holds `summary_text` parts for skim / CLI clients. When the +/// upstream only exposes raw thinking (DeepSeek `reasoning_content`, Gemini +/// thoughts, Claude thinking), the same text is copied into both so neither +/// client family loses the panel. +pub(crate) fn openai_responses_reasoning_text_fields( + texts: impl IntoIterator>, +) -> (Value, Value) { + let texts: Vec = texts + .into_iter() + .map(|text| text.as_ref().to_string()) + .filter(|text| !text.trim().is_empty()) + .collect(); + let content = texts + .iter() + .map(|text| json!({ "type": "reasoning_text", "text": text })) + .collect::>(); + let summary = texts + .iter() + .map(|text| json!({ "type": "summary_text", "text": text })) + .collect::>(); + (Value::Array(content), Value::Array(summary)) +} + +/// Writes raw thinking onto a Responses reasoning item without clobbering an +/// existing structured summary or provider-owned content. +pub(crate) fn apply_openai_responses_reasoning_text(item: &mut Map, text: &str) { + if text.trim().is_empty() { + return; + } + let (content, summary) = openai_responses_reasoning_text_fields(std::iter::once(text)); + if reasoning_item_field_is_empty(item.get("content")) { + item.insert("content".to_string(), content); + } + if reasoning_item_field_is_empty(item.get("summary")) { + item.insert("summary".to_string(), summary); + } +} + +fn reasoning_item_field_is_empty(value: Option<&Value>) -> bool { + match value { + None | Some(Value::Null) => true, + Some(Value::Array(parts)) => parts.is_empty(), + Some(Value::String(text)) => text.trim().is_empty(), + _ => false, + } +} + /// Repairs legacy/non-OpenAI message IDs in a Responses request in place. /// /// Aether versions before the `msg_` contract emitted IDs such as @@ -419,6 +471,36 @@ mod tests { assert_ne!(first, other); } + #[test] + fn reasoning_text_fields_put_raw_thinking_in_content_and_summary() { + let (content, summary) = super::openai_responses_reasoning_text_fields(["raw chain"]); + assert_eq!( + content, + json!([{ "type": "reasoning_text", "text": "raw chain" }]) + ); + assert_eq!( + summary, + json!([{ "type": "summary_text", "text": "raw chain" }]) + ); + + let mut item = serde_json::Map::new(); + super::apply_openai_responses_reasoning_text(&mut item, "raw chain"); + assert_eq!(item["content"], content); + assert_eq!(item["summary"], summary); + + item.insert( + "summary".to_string(), + json!([{ "type": "summary_text", "text": "kept" }]), + ); + item.insert("content".to_string(), json!([])); + super::apply_openai_responses_reasoning_text(&mut item, "replacement"); + assert_eq!( + item["content"], + json!([{ "type": "reasoning_text", "text": "replacement" }]) + ); + assert_eq!(item["summary"][0]["text"], "kept"); + } + #[test] fn synthetic_message_item_ids_are_stable_and_start_with_msg() { let first = openai_responses_message_item_id("1c938e58-32a8-4d28-9c34-538d78076895", 0); diff --git a/crates/aether-ai/formats/src/formats/openai/responses/request.rs b/crates/aether-ai/formats/src/formats/openai/responses/request.rs index 519a2e505..cd5799d32 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/request.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/request.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, VecDeque}; use serde_json::{json, Map, Value}; -use super::encode_tool_result_error; +use super::{apply_openai_responses_reasoning_text, encode_tool_result_error}; use crate::{ formats::context::FormatContext, @@ -702,14 +702,7 @@ fn canonical_thinking_to_responses_reasoning_item( .unwrap_or_default(); item.remove("item_type"); item.insert("type".to_string(), Value::String("reasoning".to_string())); - if !text.trim().is_empty() { - item.entry("summary".to_string()).or_insert_with(|| { - json!([{ - "type": "summary_text", - "text": text, - }]) - }); - } + apply_openai_responses_reasoning_text(&mut item, text); if let Some(value) = encrypted_content.filter(|value| !value.is_empty()) { item.insert( "encrypted_content".to_string(), diff --git a/crates/aether-ai/formats/src/formats/openai/responses/response.rs b/crates/aether-ai/formats/src/formats/openai/responses/response.rs index 977d8b8f6..0d25d4c82 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/response.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/response.rs @@ -6,8 +6,9 @@ use std::{ use serde_json::{json, Map, Value}; use super::{ - encode_gemini_tool_signature_carrier, encode_tool_result_error, - history::record_converted_response_history, openai_responses_synthetic_reasoning_item_id, + apply_openai_responses_reasoning_text, encode_gemini_tool_signature_carrier, + encode_tool_result_error, history::record_converted_response_history, + openai_responses_synthetic_reasoning_item_id, }; use crate::{ @@ -217,15 +218,7 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bo Value::String(encrypted_content.clone()), ); } - if !text.trim().is_empty() { - item.insert( - "summary".to_string(), - Value::Array(vec![json!({ - "type": "summary_text", - "text": text, - })]), - ); - } + apply_openai_responses_reasoning_text(&mut item, text); output.push(Value::Object(item)); } CanonicalContentBlock::ToolUse { @@ -792,6 +785,66 @@ mod tests { ); } + #[test] + fn responses_response_builder_puts_raw_thinking_in_content_and_summary() { + let response = CanonicalResponse { + id: "resp_think".to_string(), + model: "deepseek-reasoner".to_string(), + content: vec![ + CanonicalContentBlock::Thinking { + text: "first add one to one".to_string(), + signature: None, + encrypted_content: None, + extensions: BTreeMap::new(), + }, + CanonicalContentBlock::Text { + text: "2".to_string(), + extensions: BTreeMap::new(), + }, + ], + outputs: Vec::new(), + stop_reason: Some(CanonicalStopReason::EndTurn), + usage: None, + extensions: BTreeMap::new(), + }; + + let body = to_raw(&response, &json!({}), false); + let item = &body["output"][0]; + + assert_eq!(item["type"], "reasoning"); + assert_eq!(item["content"][0]["type"], "reasoning_text"); + assert_eq!(item["content"][0]["text"], "first add one to one"); + assert_eq!(item["summary"][0]["type"], "summary_text"); + assert_eq!(item["summary"][0]["text"], "first add one to one"); + assert!(!item["content"].is_null()); + assert_eq!(body["output"][1]["type"], "message"); + assert_eq!(body["output"][1]["content"][0]["text"], "2"); + } + + #[test] + fn responses_response_parser_prefers_content_over_summary_for_raw_reasoning() { + let body = json!({ + "id": "resp_test", + "model": "gpt-5", + "status": "completed", + "output": [{ + "type": "reasoning", + "id": "rs_1", + "status": "completed", + "summary": [{"type": "summary_text", "text": "short summary"}], + "content": [{"type": "reasoning_text", "text": "full chain of thought"}] + }] + }); + + let canonical = from_raw(&body).expect("response should parse"); + + assert!(matches!( + canonical.content.first(), + Some(CanonicalContentBlock::Thinking { text, .. }) + if text == "full chain of thought" + )); + } + #[test] fn responses_response_parser_preserves_encrypted_reasoning_without_summary() { let body = json!({ diff --git a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs index 9351e6a25..bdf162e87 100644 --- a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs @@ -950,6 +950,10 @@ mod tests { .expect("first Gemini thought chunk should transform"); let sse = String::from_utf8(output).expect("reasoning SSE should be utf8"); + assert!( + sse.contains("event: response.reasoning_text.delta\n"), + "{sse}" + ); assert!( sse.contains("event: response.reasoning_summary_text.delta\n"), "{sse}" diff --git a/crates/aether-ai/formats/src/formats/shared/sync_products.rs b/crates/aether-ai/formats/src/formats/shared/sync_products.rs index 4a20b159f..d8c743394 100644 --- a/crates/aether-ai/formats/src/formats/shared/sync_products.rs +++ b/crates/aether-ai/formats/src/formats/shared/sync_products.rs @@ -9,7 +9,8 @@ use aether_ai_formats::formats::conversion::response::{ }; use aether_ai_formats::formats::openai::responses::response::ensure_modern_openai_responses_response_fields; use aether_ai_formats::formats::openai::responses::{ - openai_responses_message_item_id, openai_responses_synthetic_reasoning_item_id, + openai_responses_message_item_id, openai_responses_reasoning_text_fields, + openai_responses_synthetic_reasoning_item_id, }; use aether_ai_formats::formats::registry::{convert_response, FormatContext, FormatError}; use aether_ai_formats::{ @@ -3117,19 +3118,27 @@ fn merge_openai_responses_tool_arguments( } fn extract_openai_responses_reasoning_text(item: &Map) -> Option { - item.get("summary") - .and_then(Value::as_array) + extract_openai_responses_reasoning_parts(item.get("content"), "reasoning_text") + .or_else(|| extract_openai_responses_reasoning_parts(item.get("summary"), "summary_text")) +} + +fn extract_openai_responses_reasoning_parts( + raw: Option<&Value>, + expected_type: &str, +) -> Option { + raw.and_then(Value::as_array) .into_iter() .flatten() .find_map(|part| { let part = part.as_object()?; - (part.get("type").and_then(Value::as_str) == Some("summary_text")).then(|| { + (part.get("type").and_then(Value::as_str) == Some(expected_type)).then(|| { part.get("text") .and_then(Value::as_str) .unwrap_or_default() .to_string() }) }) + .filter(|text| !text.is_empty()) } fn merge_openai_responses_message_item( @@ -3276,17 +3285,26 @@ fn materialize_openai_responses_reasoning_item( item.entry("status".to_string()) .or_insert_with(|| Value::String("completed".to_string())); if !state.summary_text.is_empty() { - item.insert( - "summary".to_string(), - Value::Array(vec![json!({ - "type": "summary_text", - "text": state.summary_text, - })]), - ); + let (content, summary) = openai_responses_reasoning_text_fields([&state.summary_text]); + if reasoning_item_field_missing_or_empty(item.get("content")) { + item.insert("content".to_string(), content); + } + if reasoning_item_field_missing_or_empty(item.get("summary")) { + item.insert("summary".to_string(), summary); + } } Value::Object(item) } +fn reasoning_item_field_missing_or_empty(value: Option<&Value>) -> bool { + match value { + None | Some(Value::Null) => true, + Some(Value::Array(parts)) => parts.is_empty(), + Some(Value::String(text)) => text.trim().is_empty(), + _ => false, + } +} + fn materialize_openai_responses_tool_item( output_index: usize, state: OpenAIResponsesSyncToolState, @@ -5689,6 +5707,8 @@ mod tests { openai_responses_synthetic_reasoning_item_id("resp_summary_123", 0) ); assert_eq!(materialized["summary"][0]["text"], "Need care"); + assert_eq!(materialized["content"][0]["type"], "reasoning_text"); + assert_eq!(materialized["content"][0]["text"], "Need care"); } #[test] diff --git a/crates/aether-ai/formats/src/protocol/canonical.rs b/crates/aether-ai/formats/src/protocol/canonical.rs index 55b0d7661..c3ad55edd 100644 --- a/crates/aether-ai/formats/src/protocol/canonical.rs +++ b/crates/aether-ai/formats/src/protocol/canonical.rs @@ -2860,9 +2860,9 @@ fn openai_responses_reasoning_block_from_item( } fn openai_responses_reasoning_text(item_object: &Map) -> String { - let mut parts = openai_responses_reasoning_text_parts(item_object.get("summary")); + let mut parts = openai_responses_reasoning_text_parts(item_object.get("content")); if parts.is_empty() { - parts = openai_responses_reasoning_text_parts(item_object.get("content")); + parts = openai_responses_reasoning_text_parts(item_object.get("summary")); } parts.join("\n") } @@ -2961,38 +2961,47 @@ pub(crate) fn openai_responses_output_to_canonical( .and_then(Value::as_str) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); - if let Some(summary_items) = item_object.get("summary").and_then(Value::as_array) { - for summary in summary_items { - let Some(summary_object) = summary.as_object() else { - continue; - }; - let text = summary_object - .get("text") - .and_then(Value::as_str) - .unwrap_or_default(); - if text.trim().is_empty() { - continue; - } - let mut extensions = openai_responses_extensions( - item_object, - &["type", "id", "status", "summary", "encrypted_content"], - ); - canonical_extension_object_mut(&mut extensions, "openai") - .insert("omit_reasoning_parts".to_string(), Value::Bool(true)); - let extensions = openai_thinking_extensions(extensions); - blocks.push(CanonicalContentBlock::Thinking { - text: text.to_string(), - signature: None, - encrypted_content: encrypted_content.clone(), - extensions, - }); - emitted = true; + let mut texts = openai_responses_reasoning_text_parts(item_object.get("content")); + if texts.is_empty() { + texts = openai_responses_reasoning_text_parts(item_object.get("summary")); + } + for text in texts { + if text.trim().is_empty() { + continue; } + let mut extensions = openai_responses_extensions( + item_object, + &[ + "type", + "id", + "status", + "summary", + "content", + "encrypted_content", + ], + ); + canonical_extension_object_mut(&mut extensions, "openai") + .insert("omit_reasoning_parts".to_string(), Value::Bool(true)); + let extensions = openai_thinking_extensions(extensions); + blocks.push(CanonicalContentBlock::Thinking { + text, + signature: None, + encrypted_content: encrypted_content.clone(), + extensions, + }); + emitted = true; } if !emitted && encrypted_content.is_some() { let mut extensions = openai_responses_extensions( item_object, - &["type", "id", "status", "summary", "encrypted_content"], + &[ + "type", + "id", + "status", + "summary", + "content", + "encrypted_content", + ], ); canonical_extension_object_mut(&mut extensions, "openai") .insert("omit_reasoning_parts".to_string(), Value::Bool(true)); From dfe88e34e7b5ecf909db3853a026aeaa20355e98 Mon Sep 17 00:00:00 2001 From: Kayphoon <109347466+Kayphoon@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:07:40 +0000 Subject: [PATCH 04/72] feat(providers): add multi-select batch delete for provider models Let admins select one or more models in a provider's model list and delete them together, using the existing single-model delete API and the same confirm-danger pattern as global model batch delete. --- .../components/provider-tabs/ModelsTab.vue | 177 ++++++++++-- .../__tests__/ModelsTab.batch-delete.spec.ts | 265 ++++++++++++++++++ .../__tests__/translation-coverage.spec.ts | 4 + frontend/src/i18n/messages.ts | 3 + frontend/src/mocks/handler.ts | 7 +- 5 files changed, 437 insertions(+), 19 deletions(-) create mode 100644 frontend/src/features/providers/components/provider-tabs/__tests__/ModelsTab.batch-delete.spec.ts diff --git a/frontend/src/features/providers/components/provider-tabs/ModelsTab.vue b/frontend/src/features/providers/components/provider-tabs/ModelsTab.vue index eeb7109c1..2f2ea1712 100644 --- a/frontend/src/features/providers/components/provider-tabs/ModelsTab.vue +++ b/frontend/src/features/providers/components/provider-tabs/ModelsTab.vue @@ -2,19 +2,58 @@
-
-

- 模型列表 -

- +
+
+ +

+ 模型列表 +

+ + 已选 {{ selectedCount }} 个 + +
+
+ + +
@@ -44,10 +83,19 @@
+
- 共 {{ sortedModels.length }} 个模型 + + 共 {{ sortedModels.length }} 个模型 + +
+ +
+ +
+ + + +
+ {{ legacyT('预付额度') }}: {{ formatKiroUsage(getXaiQuotaDisplay(key)?.prepaid_balance) }} +
+ + + +
+
{ + const resetSeconds = getQuotaWindowResetSeconds(usageWindow) + if (updatedAt === undefined || resetSeconds === undefined) return undefined + return updatedAt + resetSeconds + })() + if (nextResetAt !== undefined) display.next_reset_at = nextResetAt + } + + const prepaidWindow = getQuotaWindow(quota, 'prepaid') + if (typeof prepaidWindow?.remaining_value === 'number') { + display.prepaid_balance = prepaidWindow.remaining_value + } + + const onDemandWindow = getQuotaWindow(quota, 'on_demand') + if (typeof onDemandWindow?.limit_value === 'number') display.on_demand_cap = onDemandWindow.limit_value + if (typeof onDemandWindow?.used_value === 'number') display.on_demand_used = onDemandWindow.used_value + if (typeof onDemandWindow?.remaining_value === 'number') display.on_demand_remaining = onDemandWindow.remaining_value + + return Object.keys(display).length > 0 ? display : null +} + +function hasXaiQuotaDisplayData(key: EndpointAPIKey): boolean { + const xai = getXaiQuotaDisplay(key) + return !!xai && ( + xai.usage_percentage !== undefined + || xai.remaining_percentage !== undefined + || xai.prepaid_balance !== undefined + || xai.on_demand_cap !== undefined + ) +} + +function getXaiUsageLabel(key: EndpointAPIKey): string { + const display = getXaiQuotaDisplay(key) + if (display?.usage_label) return display.usage_label + const title = display?.subscription_title + return title ? `使用额度 (${title})` : '使用额度' +} + +function getXaiUsedPercent(key: EndpointAPIKey): number { + return Math.min(Math.max(100 - getXaiRemainingPercent(key), 0), 100) +} + +function getXaiRemainingPercent(key: EndpointAPIKey): number { + const xai = getXaiQuotaDisplay(key) + if (xai?.remaining_percentage != null && Number.isFinite(xai.remaining_percentage)) { + return Math.min(Math.max(xai.remaining_percentage, 0), 100) + } + if (xai?.usage_percentage != null && Number.isFinite(xai.usage_percentage)) { + return Math.min(Math.max(100 - xai.usage_percentage, 0), 100) + } + return 0 +} + +function getXaiOnDemandUsedPercent(key: EndpointAPIKey): number { + const xai = getXaiQuotaDisplay(key) + if (!xai?.on_demand_cap || xai.on_demand_cap <= 0) return 0 + return Math.max(Math.min(((xai.on_demand_used || 0) / xai.on_demand_cap) * 100, 100), 0) +} + type GrokQuotaDisplay = GrokUpstreamMetadata & { usage_percentage?: number usage_limit?: number @@ -2696,6 +2840,28 @@ function shouldAutoRefreshGrokQuota(): boolean { return false } +function shouldAutoRefreshXaiQuota(): boolean { + if (provider.value?.provider_type !== 'xai') return false + const now = Math.floor(Date.now() / 1000) + + for (const { key } of allKeys.value) { + if (!key.is_active) continue + + if (isTokenExpiringSoon(key, now)) return true + + if (!hasXaiQuotaDisplayData(key)) { + return true + } + + const updatedAt = getXaiQuotaDisplay(key)?.updated_at + if (typeof updatedAt !== 'number' || (now - updatedAt) > AUTO_QUOTA_REFRESH_STALE_SECONDS) { + return true + } + } + + return false +} + function shouldAutoRefreshWindsurfQuota(): boolean { if (provider.value?.provider_type !== 'windsurf') return false const now = Math.floor(Date.now() / 1000) @@ -2824,7 +2990,7 @@ async function autoRefreshQuotaInBackground(): Promise { if (refreshingQuota.value) return false const providerType = provider.value?.provider_type - if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok') return false + if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok' && providerType !== 'xai') return false // 检查是否需要刷新 let shouldRefresh = false @@ -2838,6 +3004,8 @@ async function autoRefreshQuotaInBackground(): Promise { shouldRefresh = shouldAutoRefreshKiroQuota() } else if (providerType === 'grok') { shouldRefresh = shouldAutoRefreshGrokQuota() + } else if (providerType === 'xai') { + shouldRefresh = shouldAutoRefreshXaiQuota() } else if (providerType === 'windsurf') { shouldRefresh = shouldAutoRefreshWindsurfQuota() } else if (providerType === 'chatgpt_web') { @@ -2856,6 +3024,8 @@ async function autoRefreshQuotaInBackground(): Promise { hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasKiroQuotaDisplayData(key)) } else if (providerType === 'grok') { hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasGrokQuotaDisplayData(key)) + } else if (providerType === 'xai') { + hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasXaiQuotaDisplayData(key)) } else if (providerType === 'windsurf') { hadCachedQuota = allKeys.value.some(({ key }) => key.is_active && hasWindsurfQuotaDisplayData(key)) } else if (providerType === 'chatgpt_web') { diff --git a/frontend/src/features/providers/components/ProviderFormDialog.vue b/frontend/src/features/providers/components/ProviderFormDialog.vue index 727528acf..c8720e027 100644 --- a/frontend/src/features/providers/components/ProviderFormDialog.vue +++ b/frontend/src/features/providers/components/ProviderFormDialog.vue @@ -60,6 +60,9 @@ Grok + + xAI + Kiro @@ -93,6 +96,9 @@ Grok + + xAI + Kiro diff --git a/frontend/src/features/providers/components/__tests__/provider-quota-display.spec.ts b/frontend/src/features/providers/components/__tests__/provider-quota-display.spec.ts index a7b9ae8d4..8eee317a3 100644 --- a/frontend/src/features/providers/components/__tests__/provider-quota-display.spec.ts +++ b/frontend/src/features/providers/components/__tests__/provider-quota-display.spec.ts @@ -54,6 +54,24 @@ describe('provider quota display components', () => { unmount() }) + it('fills the remaining bar even when used percent is zero', () => { + const { root, unmount } = mount(ProviderQuotaProgressRow, { + label: '周额度', + usedPercent: 0, + remainingPercent: 86, + meterClass: 'text-green-600', + barClass: 'bg-green-500', + resetText: '5天0小时后重置', + }) + + expect(root.querySelector('[data-testid="provider-quota-progress-meter"]')?.textContent?.trim()).toBe('86.0%') + expect((root.querySelector('[data-testid="provider-quota-progress-bar"]') as HTMLElement).style.width).toBe('86%') + expect(root.textContent).toContain('周额度') + expect(root.querySelector('[data-testid="provider-quota-progress-reset"]')?.textContent).toBe('5天0小时后重置') + + unmount() + }) + it('renders section loading and updated state', () => { const Probe = defineComponent({ setup() { diff --git a/frontend/src/features/providers/components/provider-tabs/ModelTestDialog.vue b/frontend/src/features/providers/components/provider-tabs/ModelTestDialog.vue index ca9de71af..10ee913cf 100644 --- a/frontend/src/features/providers/components/provider-tabs/ModelTestDialog.vue +++ b/frontend/src/features/providers/components/provider-tabs/ModelTestDialog.vue @@ -1386,6 +1386,7 @@ function formatAuthType(authType: string): string { if (lowered === 'antigravity') return 'Antigravity OAuth' if (lowered === 'kiro') return 'Kiro OAuth' if (lowered === 'grok') return 'Grok OAuth' + if (lowered === 'xai') return 'xAI OAuth' return authType } diff --git a/frontend/src/features/providers/components/provider-tabs/model-test-capabilities.ts b/frontend/src/features/providers/components/provider-tabs/model-test-capabilities.ts index 56148567b..790c65915 100644 --- a/frontend/src/features/providers/components/provider-tabs/model-test-capabilities.ts +++ b/frontend/src/features/providers/components/provider-tabs/model-test-capabilities.ts @@ -39,10 +39,12 @@ const MODEL_TEST_OAUTH_INHERITS_PROVIDER_FORMATS = new Set([ 'vertex_ai', 'antigravity', 'kiro', + 'xai', ]) const MODEL_TEST_BEARER_INHERITS_PROVIDER_FORMATS = new Set([ 'chatgpt_web', + 'xai', ]) const MODEL_TEST_DIAGNOSTIC_LABELS: Record = { diff --git a/frontend/src/features/providers/utils/__tests__/providerTypeUtils.spec.ts b/frontend/src/features/providers/utils/__tests__/providerTypeUtils.spec.ts index 6ef1ece4b..75ef811a5 100644 --- a/frontend/src/features/providers/utils/__tests__/providerTypeUtils.spec.ts +++ b/frontend/src/features/providers/utils/__tests__/providerTypeUtils.spec.ts @@ -16,6 +16,12 @@ describe('providerTypeUtils', () => { expect(isKeyManagedProviderType('grok')).toBe(false) }) + it('treats xAI as an OAuth account provider', () => { + expect(isOAuthAccountProviderType('xai')).toBe(true) + expect(isOAuthAccountProviderType('xAI')).toBe(true) + expect(isKeyManagedProviderType('xai')).toBe(false) + }) + it('treats Windsurf as an OAuth account provider', () => { expect(isOAuthAccountProviderType('windsurf')).toBe(true) expect(isOAuthAccountProviderType('Windsurf')).toBe(true) diff --git a/frontend/src/features/providers/utils/providerTypeUtils.ts b/frontend/src/features/providers/utils/providerTypeUtils.ts index 2ea917f52..33e475bd1 100644 --- a/frontend/src/features/providers/utils/providerTypeUtils.ts +++ b/frontend/src/features/providers/utils/providerTypeUtils.ts @@ -12,6 +12,7 @@ const oauthAccountProviderTypes = new Set([ 'antigravity', 'kiro', 'grok', + 'xai', 'windsurf', ]) diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index e53bdf21e..dd58b6316 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -2146,7 +2146,10 @@ const legacyExactEnglishMessages: Record = { '账号不可用': 'Account unavailable', '日额度': 'Daily quota', '周额度': 'Weekly quota', + '月额度': 'Monthly quota', '剩余额度': 'Remaining quota', + '预付额度': 'Prepaid credits', + '按需额度': 'On-demand credits', '点击编辑优先级': 'Edit priority', '点击编辑倍率': 'Edit multiplier', '同步失败': 'Sync failed', diff --git a/frontend/src/utils/__tests__/providerKeyQuota.spec.ts b/frontend/src/utils/__tests__/providerKeyQuota.spec.ts index e1737d9df..ccfda2f14 100644 --- a/frontend/src/utils/__tests__/providerKeyQuota.spec.ts +++ b/frontend/src/utils/__tests__/providerKeyQuota.spec.ts @@ -359,4 +359,30 @@ describe('providerKeyQuota', () => { }, }, 'windsurf')).toBe('可用模型 3 个') }) + + it('formats xAI weekly credits as remaining percent', () => { + expect(getQuotaDisplayText({ + status_snapshot: { + oauth: { code: 'valid' }, + account: { code: 'ok', blocked: false }, + quota: { + provider_type: 'xai', + code: 'ok', + exhausted: false, + windows: [ + { + code: 'usage', + scope: 'account', + used_ratio: 0.46, + remaining_ratio: 0.54, + }, + { + code: 'prepaid', + remaining_value: 12.5, + }, + ], + }, + }, + }, 'xai')).toBe('剩余 54.0% | 预付剩余 12.5') + }) }) diff --git a/frontend/src/utils/oauth-icons.ts b/frontend/src/utils/oauth-icons.ts index c6c555e4e..5d9d14285 100644 --- a/frontend/src/utils/oauth-icons.ts +++ b/frontend/src/utils/oauth-icons.ts @@ -5,6 +5,7 @@ export const OAUTH_ICONS: Record = { google: ``, gemini_cli: ``, grok: ``, + xai: ``, } // Default icon when provider type is not found diff --git a/frontend/src/utils/providerKeyQuota.ts b/frontend/src/utils/providerKeyQuota.ts index 22f681922..61ba49070 100644 --- a/frontend/src/utils/providerKeyQuota.ts +++ b/frontend/src/utils/providerKeyQuota.ts @@ -265,6 +265,29 @@ function getKiroQuotaText(quota: QuotaStatusSnapshot): string | null { return normalizeText(quota.label) } +function getXaiQuotaText(quota: QuotaStatusSnapshot): string | null { + const parts: string[] = [] + const usageText = getKiroQuotaText(quota) + if (usageText) parts.push(usageText) + + const prepaid = getQuotaWindow(quota, 'prepaid') + if (typeof prepaid?.remaining_value === 'number') { + parts.push(`预付剩余 ${formatQuotaValue(prepaid.remaining_value)}`) + } + + const onDemand = getQuotaWindow(quota, 'on_demand') + const onDemandRemaining = getQuotaWindowRemainingPercent(onDemand) + if (onDemandRemaining != null) { + const valueText = getQuotaWindowValueText(onDemand) + parts.push(`按需剩余 ${formatPercent(onDemandRemaining)}${valueText ? ` (${valueText})` : ''}`) + } else if (typeof onDemand?.remaining_value === 'number') { + parts.push(`按需剩余 ${formatQuotaValue(onDemand.remaining_value)}`) + } + + if (parts.length > 0) return parts.join(' | ') + return normalizeText(quota.label) +} + function getGrokQuotaText(quota: QuotaStatusSnapshot): string | null { const code = normalizeText(quota.code)?.toLowerCase() if (code === 'banned') { @@ -452,6 +475,8 @@ export function getQuotaSnapshotFallbackText( return getCodexQuotaText(quota) case 'kiro': return getKiroQuotaText(quota) + case 'xai': + return getXaiQuotaText(quota) case 'grok': return getGrokQuotaText(quota) case 'windsurf': diff --git a/frontend/src/views/admin/PoolManagement.vue b/frontend/src/views/admin/PoolManagement.vue index a853829b6..1e05aac93 100644 --- a/frontend/src/views/admin/PoolManagement.vue +++ b/frontend/src/views/admin/PoolManagement.vue @@ -1700,6 +1700,7 @@ const showAccountQuotaColumn = computed(() => { || selectedProviderType.value === 'antigravity' || selectedProviderType.value === 'grok' || selectedProviderType.value === 'chatgpt_web' + || selectedProviderType.value === 'xai' }) const desktopColumnWidths = computed(() => { @@ -2149,6 +2150,7 @@ const quotaRefreshSupported = computed(() => { || selectedProviderType.value === 'antigravity' || selectedProviderType.value === 'grok' || selectedProviderType.value === 'chatgpt_web' + || selectedProviderType.value === 'xai' }) function canResetCycleStats(_key: PoolKeyDetail): boolean { @@ -3480,8 +3482,10 @@ function normalizeQuotaLabel(label: string): string { if (/spark/i.test(normalized) && normalized.includes('周')) return 'Spark周' if (normalized.includes('5H')) return '5H' if (normalized.includes('周')) return '周' + if (normalized.includes('月')) return '月' if (normalized.includes('最低剩余')) return '最低' if (normalized === '剩余' || normalized.includes('剩余')) return '剩余' + if (normalized === '额度') return '额度' return normalized } @@ -3490,6 +3494,9 @@ function getQuotaProgressLabel(label: string): string { if (label === '5H') return '5H' if (label === '周') return '周' if (label === '月') return '月' + if (label === '周额度') return '周' + if (label === '月额度') return '月' + if (label === '额度') return '额度' if (label === 'Spark5H') return 'Spark5H' if (label === 'Spark周') return 'Spark周' if (label === '最低') return '最低' @@ -3498,7 +3505,7 @@ function getQuotaProgressLabel(label: string): string { } function getQuotaProgressCountdown(item: QuotaProgressItem) { - const staticResetLabels = ['日', '5H', '周', '月', 'Spark5H', 'Spark周', 'Spark月', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图'] + const staticResetLabels = ['日', '5H', '周', '月', '周额度', '月额度', '额度', 'Spark5H', 'Spark周', 'Spark月', 'Auto', 'Fast', 'Expert', 'Heavy', 'Grok 4.3', '生图'] if (!item.allowDynamicReset && !staticResetLabels.includes(item.label)) return null if (item.resetAtSeconds == null && item.resetSeconds == null) return null return getCodexResetCountdown( @@ -3567,6 +3574,7 @@ function getQuotaLabelOrder(label: string): number { if (label === 'Prompt') return 12 if (label === 'Flex') return 13 if (label === '剩余') return 14 + if (label === '额度') return 14 if (label === '最低') return 15 if (label === '生图') return 16 if (label === '速率') return 17 @@ -3758,7 +3766,7 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI .filter((item): item is QuotaProgressItem => item != null) } - if (providerType === 'kiro') { + if (providerType === 'kiro' || providerType === 'xai') { const quotaResetAtSeconds = getQuotaSnapshotResetAtSeconds(quota) const quotaResetSeconds = getQuotaSnapshotResetSeconds(quota) const window = getQuotaSnapshotWindow(quota, 'usage') @@ -3772,12 +3780,13 @@ function buildQuotaProgressItemsFromSnapshot(key: PoolKeyDetail): QuotaProgressI : undefined return [{ - label: '剩余', + label: normalizeQuotaLabel(String(window?.label || '').trim() || '剩余'), remainingPercent, detail, resetAtSeconds: normalizeUnixSeconds(window?.reset_at ?? quotaResetAtSeconds ?? null), resetSeconds: normalizeRemainingSeconds(window?.reset_seconds ?? quotaResetSeconds ?? null), updatedAtSeconds: getQuotaSnapshotUpdatedAtSeconds(quota), + allowDynamicReset: true, }] } From 04c4a977665599809baf82397090b3838b4f0d1f Mon Sep 17 00:00:00 2001 From: stabey <36232531+stabey@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:17:21 +0800 Subject: [PATCH 09/72] feat(xai): add native image and video endpoints Expose the xAI Imagine image and video surfaces on top of the `xai` provider, and make the shared OpenAI video-task layer survive the production configuration they need. Native video requests live under /v1 (generations, edits, extensions, with /v1/videos as a creation alias that only selects xAI candidates); the OpenAI-compatible adapter stays under /openai/v1/videos and maps `seconds` / `size` onto numeric duration, aspect ratio and resolution. Clients receive an opaque Aether task ID scoped to the owning user; polling uses the upstream task ID and the original credential, and completed downloads fetch the returned media URL without forwarding provider authorization to the media host. Three fixes to the shared video layer are required for this to work outside tests: - OpenAI/xAI task persistence now supplies a stable 16-character short_id, which the PostgreSQL schema requires. Existing rows keep their original value across reconstruction, so no schema change or historical rewrite is needed. - Task retrieval and content downloads are admitted by the production GET execution gate, and reconstructed tasks resolve proxy nodes, system proxy defaults, tunnel affinity and transport profiles through the same deployment resolver used for creation. A configured proxy route no longer silently becomes a direct request after restart. - When the gateway also serves the frontend, /openai/v1/videos and its subpaths bypass the static SPA handler. Otherwise a video query returns HTTP 200 with text/html instead of the task JSON. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 1 + .../planner/specialized/image/request.rs | 11 +- .../planner/specialized/video/decision.rs | 26 +- .../planner/specialized/video/request.rs | 94 +++- apps/aether-gateway/src/api/ai/registry.rs | 2 + apps/aether-gateway/src/async_task/runtime.rs | 3 + apps/aether-gateway/src/constants.rs | 2 + apps/aether-gateway/src/control/route/ai.rs | 6 +- .../src/data/state/testing/video_tasks.rs | 9 + .../src/executor/orchestration.rs | 22 +- .../src/frontdoor_loop_guard.rs | 4 + apps/aether-gateway/src/image_capabilities.rs | 3 +- apps/aether-gateway/src/router.rs | 2 + apps/aether-gateway/src/state/integrations.rs | 8 + apps/aether-gateway/src/tests/video/mod.rs | 38 +- .../src/tests/video/registry_poller.rs | 10 +- .../aether-gateway/src/tests/video/routing.rs | 32 +- apps/aether-gateway/src/tests/video/xai.rs | 427 ++++++++++++++++++ .../src/video_tasks/tests/plans.rs | 21 + .../src/video_tasks/tests/projection.rs | 9 + .../src/video_tasks/tests/sync.rs | 6 + .../src/formats/openai/responses/xai.rs | 4 +- .../formats/src/formats/shared/routing.rs | 13 +- .../postgres/src/candidate_selection.rs | 32 +- .../repository/candidate_selection/memory.rs | 36 +- crates/aether-model-fetch/src/logic.rs | 13 +- .../transport/src/conversion.rs | 45 +- .../transport/src/openai_image/mod.rs | 63 ++- .../transport/src/provider_types.rs | 21 +- .../transport/src/video/mod.rs | 246 +++++++++- crates/aether-provider/transport/src/xai.rs | 35 ++ .../transport/src/xai/video.rs | 147 ++++++ crates/aether-video-tasks-core/Cargo.toml | 1 + crates/aether-video-tasks-core/src/openai.rs | 198 +++++++- crates/aether-video-tasks-core/src/path.rs | 16 +- .../aether-video-tasks-core/src/read_side.rs | 5 +- crates/aether-video-tasks-core/src/service.rs | 11 +- .../aether-video-tasks-core/src/snapshot.rs | 17 + crates/aether-video-tasks-core/src/sync.rs | 290 +++++++++++- .../src/transport_domain.rs | 12 +- crates/aether-video-tasks-core/src/types.rs | 7 + docs/operations/xai-provider.md | 96 +++- 42 files changed, 1880 insertions(+), 164 deletions(-) create mode 100644 apps/aether-gateway/src/tests/video/xai.rs create mode 100644 crates/aether-provider/transport/src/xai/video.rs diff --git a/Cargo.lock b/Cargo.lock index d83fc53c1..e475509d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -748,6 +748,7 @@ dependencies = [ "async-trait", "serde", "serde_json", + "sha2", "url", "uuid", ] diff --git a/apps/aether-gateway/src/ai_serving/planner/specialized/image/request.rs b/apps/aether-gateway/src/ai_serving/planner/specialized/image/request.rs index 3ca11f0d2..be3ff2fc0 100644 --- a/apps/aether-gateway/src/ai_serving/planner/specialized/image/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/specialized/image/request.rs @@ -17,8 +17,8 @@ use crate::ai_serving::transport::{ ProviderOpenAiImageHeadersInput, StandardProviderRequestHeadersInput, GROK_CHAT_PATH, }; use crate::ai_serving::{ - apply_codex_openai_special_headers, build_chatgpt_web_image_request_body, - build_codex_openai_image_api_provider_request_body, + apply_codex_openai_special_headers, apply_xai_upstream_payload_edits, + build_chatgpt_web_image_request_body, build_codex_openai_image_api_provider_request_body, build_gemini_image_request_body_from_openai_image_request, build_openai_image_api_provider_request_body, build_openai_image_provider_request_body, default_model_for_openai_image_operation, normalize_openai_image_request, @@ -211,7 +211,7 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts( upstream_is_stream, ) }; - let Some(provider_request_body) = provider_request_body else { + let Some(mut provider_request_body) = provider_request_body else { mark_skipped_local_openai_image_candidate_with_failure_diagnostic( state, input, @@ -229,6 +229,11 @@ pub(super) async fn resolve_local_openai_image_candidate_payload_parts( .await; return None; }; + apply_xai_upstream_payload_edits( + &mut provider_request_body, + transport.provider.provider_type.as_str(), + provider_api_format, + ); let Some(mut provider_request_headers) = (if is_grok { build_grok_browser_headers(GrokHeaderInput { transport, diff --git a/apps/aether-gateway/src/ai_serving/planner/specialized/video/decision.rs b/apps/aether-gateway/src/ai_serving/planner/specialized/video/decision.rs index c1207443e..8120ca3d7 100644 --- a/apps/aether-gateway/src/ai_serving/planner/specialized/video/decision.rs +++ b/apps/aether-gateway/src/ai_serving/planner/specialized/video/decision.rs @@ -8,6 +8,7 @@ use crate::ai_serving::planner::{ build_ai_execution_decision_response, resolve_transport_request_encoding_policy, AiExecutionDecisionResponseParts, }; +use crate::ai_serving::transport::xai::video::is_native_video_request; use crate::ai_serving::transport::{ resolve_transport_execution_timeouts, resolve_transport_profile, }; @@ -33,7 +34,7 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat let Some(resolved) = resolve_local_video_create_candidate_payload_parts( state, parts, body_json, trace_id, input, &attempt, spec, ) - .await + .await? else { return Ok(None); }; @@ -52,9 +53,32 @@ pub(super) async fn maybe_build_local_video_create_decision_payload_for_candidat .await; let transport_profile = resolve_transport_profile(&transport); let mut extra_fields = serde_json::Map::new(); + if is_native_video_request(&transport.provider.provider_type, parts.uri.path()) { + extra_fields.insert( + "video_client_protocol".to_string(), + serde_json::json!("xai"), + ); + } + if let Some(proxy_value) = build_request_trace_proxy_value(Some(&transport), proxy.as_ref()) { extra_fields.insert("proxy".to_string(), proxy_value); } + if transport.provider.provider_type.eq_ignore_ascii_case("xai") { + extra_fields.insert("video_provider_xai".into(), serde_json::json!(true)); + if let Some(duration) = resolved.provider_request_body.get("duration") { + extra_fields.insert("video_duration".into(), duration.clone()); + } + if parts.uri.path() == "/openai/v1/videos" { + extra_fields.insert( + "video_size".into(), + body_json + .get("size") + .filter(|v| v.as_str().is_some_and(|s| !s.trim().is_empty())) + .cloned() + .unwrap_or_else(|| serde_json::json!("720x1280")), + ); + } + } let effective_headers = input.effective_headers(&parts.headers); let report_context = build_local_execution_report_context(LocalExecutionReportContextParts { auth_context: &input.auth_context, diff --git a/apps/aether-gateway/src/ai_serving/planner/specialized/video/request.rs b/apps/aether-gateway/src/ai_serving/planner/specialized/video/request.rs index 795384f94..5910ba566 100644 --- a/apps/aether-gateway/src/ai_serving/planner/specialized/video/request.rs +++ b/apps/aether-gateway/src/ai_serving/planner/specialized/video/request.rs @@ -3,15 +3,23 @@ use std::sync::Arc; use serde_json::Value; -use crate::ai_serving::planner::candidate_preparation::resolve_candidate_mapped_model; +use crate::ai_serving::planner::candidate_preparation::{ + prepare_header_authenticated_candidate, resolve_candidate_mapped_model, OauthPreparationContext, +}; use crate::ai_serving::planner::spec_metadata::local_video_create_spec_metadata; +use crate::ai_serving::transport::xai::video::{ + convert_openai_video_request, is_explicit_native_video_path, is_native_video_request, +}; use crate::ai_serving::transport::{ build_video_create_headers, build_video_create_request_body, build_video_create_upstream_url, resolve_video_create_auth, video_create_transport_unsupported_reason, ProviderVideoCreateFamily, ProviderVideoCreateHeadersInput, }; -use crate::ai_serving::{CandidateFailureDiagnostic, GatewayProviderTransportSnapshot}; -use crate::AppState; +use crate::ai_serving::{ + apply_xai_upstream_payload_edits, CandidateFailureDiagnostic, GatewayProviderTransportSnapshot, + PlannerAppState, +}; +use crate::{AppState, GatewayError}; use super::support::{ mark_skipped_local_video_candidate, mark_skipped_local_video_candidate_with_failure_diagnostic, @@ -37,11 +45,16 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts( input: &LocalVideoCreateDecisionInput, attempt: &LocalVideoCreateCandidateAttempt, spec: LocalVideoCreateSpec, -) -> Option { +) -> Result, GatewayError> { let spec_metadata = local_video_create_spec_metadata(spec); let candidate = &attempt.eligible.candidate; let transport = &attempt.eligible.transport; let effective_headers = input.effective_headers(&parts.headers); + if is_explicit_native_video_path(parts.uri.path()) + && !transport.provider.provider_type.eq_ignore_ascii_case("xai") + { + return Ok(None); + } let provider_family = provider_video_create_family(spec.family); let transport_unsupported_reason = video_create_transport_unsupported_reason( @@ -60,23 +73,39 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts( skip_reason, ) .await; - return None; + return Ok(None); } - let auth = resolve_video_create_auth(transport, provider_family); - let Some((auth_header, auth_value)) = auth else { - mark_skipped_local_video_candidate( - state, - input, + let prepared_candidate = match prepare_header_authenticated_candidate( + PlannerAppState::new(state), + transport, + candidate, + resolve_video_create_auth(transport, provider_family), + OauthPreparationContext { trace_id, - candidate, - attempt.candidate_index, - &attempt.candidate_id, - "transport_auth_unavailable", - ) - .await; - return None; + api_format: spec_metadata.api_format, + operation: "video_create_candidate_request", + }, + ) + .await + { + Ok(prepared) => prepared, + Err(skip_reason) => { + mark_skipped_local_video_candidate( + state, + input, + trace_id, + candidate, + attempt.candidate_index, + &attempt.candidate_id, + skip_reason, + ) + .await; + return Ok(None); + } }; + let auth_header = prepared_candidate.auth_header; + let auth_value = prepared_candidate.auth_value; let mapped_model = match resolve_candidate_mapped_model(candidate) { Ok(mapped_model) => mapped_model, @@ -91,7 +120,7 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts( skip_reason, ) .await; - return None; + return Ok(None); } }; @@ -117,10 +146,10 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts( ), ) .await; - return None; + return Ok(None); }; - let Some(provider_request_body) = build_video_create_request_body( + let Some(mut provider_request_body) = build_video_create_request_body( body_json, provider_family, &mapped_model, @@ -142,11 +171,28 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts( ), ) .await; - return None; + return Ok(None); }; + if transport.provider.provider_type.eq_ignore_ascii_case("xai") + && !is_native_video_request(&transport.provider.provider_type, parts.uri.path()) + { + provider_request_body = + convert_openai_video_request(&provider_request_body).map_err(|message| { + GatewayError::Client { + status: http::StatusCode::BAD_REQUEST, + message: message.to_string(), + } + })?; + } + apply_xai_upstream_payload_edits( + &mut provider_request_body, + transport.provider.provider_type.as_str(), + spec_metadata.api_format, + ); let Some(provider_request_headers) = build_video_create_headers(ProviderVideoCreateHeadersInput { + transport, headers: effective_headers, auth_header: &auth_header, auth_value: &auth_value, @@ -170,10 +216,10 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts( ), ) .await; - return None; + return Ok(None); }; - Some(LocalVideoCreateCandidatePayloadParts { + Ok(Some(LocalVideoCreateCandidatePayloadParts { transport: Arc::clone(transport), auth_header, auth_value, @@ -181,7 +227,7 @@ pub(super) async fn resolve_local_video_create_candidate_payload_parts( provider_request_headers, provider_request_body, upstream_url, - }) + })) } fn provider_video_create_family(family: LocalVideoCreateFamily) -> ProviderVideoCreateFamily { diff --git a/apps/aether-gateway/src/api/ai/registry.rs b/apps/aether-gateway/src/api/ai/registry.rs index 4fe6cddbf..685e0d956 100644 --- a/apps/aether-gateway/src/api/ai/registry.rs +++ b/apps/aether-gateway/src/api/ai/registry.rs @@ -53,6 +53,8 @@ const AI_ANY_ROUTE_PATTERNS: &[&str] = &[ "/v1beta/operations/{*operation_path}", "/v1/videos", "/v1/videos/{*video_path}", + "/openai/v1/videos", + "/openai/v1/videos/{*video_path}", "/upload/v1beta/files", "/v1beta/files", "/v1beta/files/{*file_path}", diff --git a/apps/aether-gateway/src/async_task/runtime.rs b/apps/aether-gateway/src/async_task/runtime.rs index d1e4a64b5..f5d301ee2 100644 --- a/apps/aether-gateway/src/async_task/runtime.rs +++ b/apps/aether-gateway/src/async_task/runtime.rs @@ -536,6 +536,9 @@ mod tests { fn sample_sparse_stored_task() -> StoredVideoTask { let snapshot = LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: false, local_task_id: "task-1".to_string(), upstream_task_id: "ext-1".to_string(), created_at_unix_ms: 1, diff --git a/apps/aether-gateway/src/constants.rs b/apps/aether-gateway/src/constants.rs index e1edeb5d9..9a2bcbe14 100644 --- a/apps/aether-gateway/src/constants.rs +++ b/apps/aether-gateway/src/constants.rs @@ -140,6 +140,8 @@ pub(crate) const RUST_FRONTDOOR_OWNED_ROUTE_PATTERNS: &[&str] = &[ "/v1beta/models/{model}/operations/{id}", "/v1beta/operations", "/v1beta/operations/{id}", + "/openai/v1/videos", + "/openai/v1/videos/{path...}", "/v1/videos", "/v1/videos/{path...}", "/upload/v1beta/files", diff --git a/apps/aether-gateway/src/control/route/ai.rs b/apps/aether-gateway/src/control/route/ai.rs index f6ac5c83b..856f62543 100644 --- a/apps/aether-gateway/src/control/route/ai.rs +++ b/apps/aether-gateway/src/control/route/ai.rs @@ -137,7 +137,11 @@ pub(super) fn classify_ai_public_route( .with_client_surface(detect_claude_client_surface(headers)) .with_api_operation(ApiOperation::ClaudeMessagesCreate), ) - } else if normalized_path.starts_with("/v1/videos") { + } else if normalized_path == "/v1/videos" + || normalized_path.starts_with("/v1/videos/") + || normalized_path == "/openai/v1/videos" + || normalized_path.starts_with("/openai/v1/videos/") + { Some(classified( "ai_public", "openai", diff --git a/apps/aether-gateway/src/data/state/testing/video_tasks.rs b/apps/aether-gateway/src/data/state/testing/video_tasks.rs index 9db104d98..24952c220 100644 --- a/apps/aether-gateway/src/data/state/testing/video_tasks.rs +++ b/apps/aether-gateway/src/data/state/testing/video_tasks.rs @@ -123,6 +123,15 @@ impl GatewayDataState { } #[cfg(test)] + pub(crate) fn attach_video_task_repository_for_tests(mut self, repository: Arc) -> Self + where + T: VideoTaskRepository + 'static, + { + self.video_task_reader = Some(repository.clone()); + self.video_task_writer = Some(repository); + self + } + pub(crate) fn with_video_task_repository_for_tests(repository: Arc) -> Self where T: VideoTaskRepository + 'static, diff --git a/apps/aether-gateway/src/executor/orchestration.rs b/apps/aether-gateway/src/executor/orchestration.rs index e79f96038..0473bfa3f 100644 --- a/apps/aether-gateway/src/executor/orchestration.rs +++ b/apps/aether-gateway/src/executor/orchestration.rs @@ -1464,6 +1464,22 @@ pub(crate) async fn maybe_execute_sync_via_local_video_decision( .await } +fn supports_local_video_get( + parts: &http::request::Parts, + decision: &GatewayControlDecision, +) -> bool { + parts.method == http::Method::GET + && decision.route_kind.as_deref() == Some("video") + && (crate::video_tasks::resolve_video_task_read_lookup_key( + decision.route_family.as_deref(), + parts.uri.path(), + ) + .is_some() + || (decision.route_family.as_deref() == Some("openai") + && crate::video_tasks::extract_openai_task_id_from_content_path(parts.uri.path()) + .is_some())) +} + pub(crate) fn maybe_execute_sync_request<'a>( state: &'a AppState, parts: &'a http::request::Parts, @@ -1477,7 +1493,7 @@ pub(crate) fn maybe_execute_sync_request<'a>( }; #[cfg(not(test))] { - if parts.method != http::Method::POST { + if parts.method != http::Method::POST && !supports_local_video_get(parts, decision) { return Ok(LocalExecutionRequestOutcome::NoPath); } return maybe_execute_sync_local_path(state, parts, body_bytes, trace_id, decision) @@ -1490,6 +1506,7 @@ pub(crate) fn maybe_execute_sync_request<'a>( .unwrap_or_default() .is_empty() && parts.method != http::Method::POST + && !supports_local_video_get(parts, decision) { return Ok(LocalExecutionRequestOutcome::NoPath); } @@ -1511,7 +1528,7 @@ pub(crate) fn maybe_execute_stream_request<'a>( }; #[cfg(not(test))] { - if parts.method != http::Method::POST { + if parts.method != http::Method::POST && !supports_local_video_get(parts, decision) { return Ok(LocalExecutionRequestOutcome::NoPath); } return maybe_execute_stream_local_path(state, parts, body_bytes, trace_id, decision) @@ -1524,6 +1541,7 @@ pub(crate) fn maybe_execute_stream_request<'a>( .unwrap_or_default() .is_empty() && parts.method != http::Method::POST + && !supports_local_video_get(parts, decision) { return Ok(LocalExecutionRequestOutcome::NoPath); } diff --git a/apps/aether-gateway/src/frontdoor_loop_guard.rs b/apps/aether-gateway/src/frontdoor_loop_guard.rs index 52c50feff..b3824be3d 100644 --- a/apps/aether-gateway/src/frontdoor_loop_guard.rs +++ b/apps/aether-gateway/src/frontdoor_loop_guard.rs @@ -32,6 +32,10 @@ fn request_has_execution_runtime_via_guard(headers: &HeaderMap) -> bool { } pub(crate) fn frontdoor_self_loop_public_ai_path(path: &str) -> bool { + let path = path + .strip_prefix("/openai") + .filter(|p| *p == "/v1/videos" || p.starts_with("/v1/videos/")) + .unwrap_or(path); matches!( path, "/v1/messages" diff --git a/apps/aether-gateway/src/image_capabilities.rs b/apps/aether-gateway/src/image_capabilities.rs index 21dc5f183..0c5c10c1d 100644 --- a/apps/aether-gateway/src/image_capabilities.rs +++ b/apps/aether-gateway/src/image_capabilities.rs @@ -13,7 +13,7 @@ pub(crate) fn openai_image_provider_max_generation_count(provider_type: &str) -> GROK_OPENAI_IMAGE_MAX_GENERATION_COUNT } else if matches!( provider_type.trim().to_ascii_lowercase().as_str(), - "openai" | "codex" + "openai" | "codex" | "xai" ) { OPENAI_IMAGE_MAX_GENERATION_COUNT } else { @@ -58,6 +58,7 @@ mod tests { assert_eq!(openai_image_provider_max_generation_count("grok"), 4); assert_eq!(openai_image_provider_max_generation_count("openai"), 10); assert_eq!(openai_image_provider_max_generation_count("codex"), 10); + assert_eq!(openai_image_provider_max_generation_count("xai"), 10); assert_eq!(openai_image_provider_max_generation_count("custom"), 1); assert_eq!( openai_image_provider_max_generation_count_for_model("openai", Some("dall-e-3")), diff --git a/apps/aether-gateway/src/router.rs b/apps/aether-gateway/src/router.rs index e7d48f818..157c46653 100644 --- a/apps/aether-gateway/src/router.rs +++ b/apps/aether-gateway/src/router.rs @@ -186,6 +186,8 @@ fn frontend_path_bypasses_static(path: &str) -> bool { "/health" | "/test-connection" | crate::constants::READYZ_PATH ) || path.starts_with("/api/") || path.starts_with("/v1/") + || path == "/openai/v1/videos" + || path.starts_with("/openai/v1/videos/") || path.starts_with("/v1beta/") || path.starts_with("/upload/") || path.starts_with("/_gateway/") diff --git a/apps/aether-gateway/src/state/integrations.rs b/apps/aether-gateway/src/state/integrations.rs index db94e5786..caf97843a 100644 --- a/apps/aether-gateway/src/state/integrations.rs +++ b/apps/aether-gateway/src/state/integrations.rs @@ -290,6 +290,14 @@ impl provider_transport::VideoTaskTransportSnapshotLookup for AppState { .await .map_err(GatewayError::into_message) } + + async fn resolve_video_task_proxy( + &self, + transport: &GatewayProviderTransportSnapshot, + ) -> Option { + self.resolve_transport_proxy_snapshot_with_tunnel_affinity(transport) + .await + } } #[async_trait] diff --git a/apps/aether-gateway/src/tests/video/mod.rs b/apps/aether-gateway/src/tests/video/mod.rs index a93ac3b2d..61b94cd08 100644 --- a/apps/aether-gateway/src/tests/video/mod.rs +++ b/apps/aether-gateway/src/tests/video/mod.rs @@ -36,6 +36,7 @@ mod openai_sync_task; mod registry_poller; mod routing; mod stream; +mod xai; /// Seed online manual proxy nodes for video execution fixtures. /// @@ -44,6 +45,17 @@ mod stream; /// the same deployment-state record; the loopback URL is never contacted when /// the execution-runtime override is active. pub(super) fn video_proxy_node_repository(node_ids: I) -> Arc +where + I: IntoIterator, + S: AsRef, +{ + video_proxy_node_repository_at_url(node_ids, "http://127.0.0.1:1") +} + +pub(super) fn video_proxy_node_repository_at_url( + node_ids: I, + proxy_url: &str, +) -> Arc where I: IntoIterator, S: AsRef, @@ -68,7 +80,7 @@ where 1, ) .expect("video test proxy node should build") - .with_manual_proxy_fields(Some("http://127.0.0.1:1".to_string()), None, None) + .with_manual_proxy_fields(Some(proxy_url.to_string()), None, None) .with_tunnel_generation(format!("video-test-generation-{node_id}")) }); Arc::new(InMemoryProxyNodeRepository::seed(nodes)) @@ -86,6 +98,28 @@ pub(super) fn video_provider_catalog_repository( endpoint_base_url: &str, key_id: &str, upstream_api_key: &str, +) -> Arc { + video_provider_catalog_repository_with_proxy( + provider_id, + provider_type, + endpoint_id, + api_format, + endpoint_base_url, + key_id, + upstream_api_key, + None, + ) +} + +pub(super) fn video_provider_catalog_repository_with_proxy( + provider_id: &str, + provider_type: &str, + endpoint_id: &str, + api_format: &str, + endpoint_base_url: &str, + key_id: &str, + upstream_api_key: &str, + proxy: Option, ) -> Arc { fn seal_bound_credential( provider_id: &str, @@ -117,7 +151,7 @@ pub(super) fn video_provider_catalog_repository( false, None, Some(2), - None, + proxy, Some(20.0), None, None, diff --git a/apps/aether-gateway/src/tests/video/registry_poller.rs b/apps/aether-gateway/src/tests/video/registry_poller.rs index b47b5938a..35bf4a0c3 100644 --- a/apps/aether-gateway/src/tests/video/registry_poller.rs +++ b/apps/aether-gateway/src/tests/video/registry_poller.rs @@ -13,7 +13,8 @@ use serde_json::json; use super::{ build_state_with_execution_runtime_override, start_server, video_provider_catalog_repository, - AppState, VideoTaskTruthSourceMode, + video_provider_catalog_repository_with_proxy, video_proxy_node_repository_at_url, AppState, + VideoTaskTruthSourceMode, }; fn sample_due_openai_task(upstream_base_url: &str) -> UpsertVideoTask { @@ -279,13 +280,13 @@ async fn gateway_background_video_task_poller_refreshes_due_openai_task_from_rep ); let (upstream_url, upstream_handle) = start_server(upstream).await; - let upstream_api_root = format!("{upstream_url}/v1"); + let upstream_api_root = "http://video-provider.invalid/v1".to_string(); let repository = Arc::new(InMemoryVideoTaskRepository::default()); repository .upsert(sample_due_openai_task(&upstream_api_root)) .await .expect("task upsert should succeed"); - let provider_catalog_repository = video_provider_catalog_repository( + let provider_catalog_repository = video_provider_catalog_repository_with_proxy( "provider-openai-video-local-1", "openai", "endpoint-openai-video-local-1", @@ -293,6 +294,7 @@ async fn gateway_background_video_task_poller_refreshes_due_openai_task_from_rep &upstream_api_root, "key-openai-video-local-1", "sk-upstream-openai-video", + Some(json!({"enabled":true,"node_id":"poller-video-proxy"})), ); let gateway_state = AppState::new() @@ -302,7 +304,7 @@ async fn gateway_background_video_task_poller_refreshes_due_openai_task_from_rep Arc::clone(&repository), provider_catalog_repository, DEVELOPMENT_ENCRYPTION_KEY, - ), + ).attach_proxy_node_repository_for_tests(video_proxy_node_repository_at_url(["poller-video-proxy"], &upstream_url)), ) .with_video_task_truth_source_mode(VideoTaskTruthSourceMode::RustAuthoritative) .with_video_task_poller_config(std::time::Duration::from_millis(25), 8); diff --git a/apps/aether-gateway/src/tests/video/routing.rs b/apps/aether-gateway/src/tests/video/routing.rs index 07f551281..ba787c274 100644 --- a/apps/aether-gateway/src/tests/video/routing.rs +++ b/apps/aether-gateway/src/tests/video/routing.rs @@ -14,8 +14,7 @@ use crate::constants::{ use super::{build_router, start_server}; #[tokio::test] -async fn gateway_locally_denies_video_control_sync_even_with_opt_in_headers_when_execution_runtime_missing( -) { +async fn gateway_hides_video_task_from_unauthenticated_caller_with_opt_in_headers() { let execute_hits = Arc::new(Mutex::new(0usize)); let execute_hits_clone = Arc::clone(&execute_hits); let public_hits = Arc::new(Mutex::new(0usize)); @@ -66,13 +65,9 @@ async fn gateway_locally_denies_video_control_sync_even_with_opt_in_headers_when .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.status(), StatusCode::NOT_FOUND); let payload: serde_json::Value = response.json().await.expect("body should parse"); - assert_eq!(payload["error"]["type"], "http_error"); - assert_eq!( - payload["error"]["message"], - "当前 OpenAI Video 请求无法在本地执行:没有匹配到可用的执行路径" - ); + assert_eq!(payload, crate::video_tasks::not_found_body()); assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0); assert_eq!(*public_hits.lock().expect("mutex should lock"), 0); @@ -81,8 +76,7 @@ async fn gateway_locally_denies_video_control_sync_even_with_opt_in_headers_when } #[tokio::test] -async fn gateway_locally_denies_video_control_sync_without_opt_in_header_when_execution_runtime_missing( -) { +async fn gateway_hides_video_task_without_calling_public_or_control_upstream() { let execute_hits = Arc::new(Mutex::new(0usize)); let execute_hits_clone = Arc::clone(&execute_hits); let public_hits = Arc::new(Mutex::new(0usize)); @@ -142,13 +136,9 @@ async fn gateway_locally_denies_video_control_sync_without_opt_in_header_when_ex .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.status(), StatusCode::NOT_FOUND); let payload: serde_json::Value = response.json().await.expect("body should parse"); - assert_eq!(payload["error"]["type"], "http_error"); - assert_eq!( - payload["error"]["message"], - "当前 OpenAI Video 请求无法在本地执行:没有匹配到可用的执行路径" - ); + assert_eq!(payload, crate::video_tasks::not_found_body()); assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0); assert_eq!(*public_hits.lock().expect("mutex should lock"), 0); assert_eq!( @@ -165,7 +155,7 @@ async fn gateway_locally_denies_video_control_sync_without_opt_in_header_when_ex } #[tokio::test] -async fn gateway_skips_video_get_control_sync_without_opt_in_header() { +async fn gateway_hides_video_task_from_unauthenticated_caller_without_opt_in_headers() { let execute_hits = Arc::new(Mutex::new(0usize)); let execute_hits_clone = Arc::clone(&execute_hits); let public_hits = Arc::new(Mutex::new(0usize)); @@ -211,13 +201,9 @@ async fn gateway_skips_video_get_control_sync_without_opt_in_header() { .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.status(), StatusCode::NOT_FOUND); let payload: serde_json::Value = response.json().await.expect("body should parse"); - assert_eq!(payload["error"]["type"], "http_error"); - assert_eq!( - payload["error"]["message"], - "当前 OpenAI Video 请求无法在本地执行:没有匹配到可用的执行路径" - ); + assert_eq!(payload, crate::video_tasks::not_found_body()); assert_eq!(*execute_hits.lock().expect("mutex should lock"), 0); assert_eq!(*public_hits.lock().expect("mutex should lock"), 0); diff --git a/apps/aether-gateway/src/tests/video/xai.rs b/apps/aether-gateway/src/tests/video/xai.rs new file mode 100644 index 000000000..059b4b844 --- /dev/null +++ b/apps/aether-gateway/src/tests/video/xai.rs @@ -0,0 +1,427 @@ +use super::*; +use aether_data::repository::auth::{ + InMemoryAuthApiKeySnapshotRepository, StoredAuthApiKeySnapshot, +}; +use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository; +use aether_data::repository::candidates::InMemoryRequestCandidateRepository; +use aether_data_contracts::repository::candidate_selection::{ + StoredMinimalCandidateSelectionRow, StoredProviderModelMapping, +}; +use sha2::{Digest, Sha256}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +fn sample_auth_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySnapshot { + StoredAuthApiKeySnapshot::new( + user_id.to_string(), + "video-user".to_string(), + Some("video@example.com".to_string()), + "user".to_string(), + "local".to_string(), + true, + false, + Some(json!(["openai"])), + Some(json!(["openai:video"])), + Some(json!(["video-model"])), + api_key_id.to_string(), + Some("default".to_string()), + true, + false, + false, + Some(60), + Some(5), + Some(4_102_444_800), + Some(json!(["openai"])), + Some(json!(["openai:video"])), + Some(json!(["video-model"])), + ) + .expect("auth snapshot should build") +} + +fn sample_candidate_row() -> StoredMinimalCandidateSelectionRow { + StoredMinimalCandidateSelectionRow { + provider_id: "provider-openai-video-local-1".to_string(), + provider_name: "openai".to_string(), + provider_type: "xai".to_string(), + provider_priority: 10, + provider_is_active: true, + endpoint_id: "endpoint-openai-video-local-1".to_string(), + endpoint_api_format: "openai:video".to_string(), + endpoint_api_family: Some("openai".to_string()), + endpoint_kind: Some("video".to_string()), + endpoint_is_active: true, + key_id: "key-openai-video-local-1".to_string(), + key_name: "prod".to_string(), + key_auth_type: "api_key".to_string(), + key_is_active: true, + key_api_formats: Some(vec!["openai:video".to_string()]), + key_allowed_models: None, + key_capabilities: None, + key_internal_priority: 5, + key_global_priority_by_format: Some(json!({"openai:video": 1})), + model_id: "model-openai-video-local-1".to_string(), + global_model_id: "global-model-openai-video-local-1".to_string(), + global_model_name: "video-model".to_string(), + global_model_mappings: None, + global_model_supports_streaming: Some(false), + model_provider_model_name: "grok-imagine-video".to_string(), + model_provider_model_mappings: Some(vec![StoredProviderModelMapping { + name: "grok-imagine-video".to_string(), + priority: 1, + api_formats: Some(vec!["openai:video".to_string()]), + endpoint_ids: None, + operations: None, + }]), + model_supports_streaming: Some(false), + model_is_active: true, + model_is_available: true, + } +} + +#[tokio::test] +async fn xai_video_native_and_compatibility_http_lifecycle() { + Box::pin(assert_xai_video_http_lifecycle(Arc::new( + InMemoryVideoTaskRepository::default(), + ))) + .await; +} + +#[tokio::test] +async fn xai_video_native_and_compatibility_http_lifecycle_postgres() { + let configured_database_url = std::env::var("AETHER_TEST_DATABASE_URL").ok(); + let managed_database = if configured_database_url.is_none() { + Some( + aether_testkit::ManagedPostgresServer::start() + .await + .expect("temporary PostgreSQL should start"), + ) + } else { + None + }; + let database_url = configured_database_url.unwrap_or_else(|| { + managed_database + .as_ref() + .expect("managed test database should exist") + .database_url() + .to_string() + }); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .expect("test database should connect"); + aether_data::driver::postgres::run_migrations(&pool) + .await + .expect("test database should migrate"); + // Preserve the production column constraints and unique indexes while isolating test rows. + sqlx::query("CREATE TEMP TABLE video_tasks (LIKE public.video_tasks INCLUDING ALL)") + .execute(&pool) + .await + .expect("isolated video task table should be created"); + let repository = + Arc::new(aether_data::repository::video_tasks::SqlxVideoTaskRepository::new(pool.clone())); + Box::pin(assert_xai_video_http_lifecycle(repository)).await; + pool.close().await; +} + +async fn assert_xai_video_http_lifecycle(repository: Arc) +where + T: aether_data_contracts::repository::video_tasks::VideoTaskRepository + 'static, +{ + let static_dir = std::env::temp_dir().join(format!( + "aether-xai-video-static-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&static_dir).unwrap(); + std::fs::write( + static_dir.join("index.html"), + "Aether test frontend", + ) + .unwrap(); + let seen = Arc::new(Mutex::new(Vec::::new())); + let calls = Arc::new(AtomicUsize::new(0)); + // Exercise the real HTTP executor, including production method gates, instead of + // the test execution-runtime override that used to hide rejected GET requests. + let video_url = Arc::new(Mutex::new(String::new())); + let runtime = Router::new() + .route("/v1/videos/{operation}", any({ + let seen = seen.clone(); + let calls = calls.clone(); + let video_url = video_url.clone(); + move |request: Request| { + let seen = seen.clone(); + let calls = calls.clone(); + let video_url = video_url.clone(); + async move { + let (parts, body) = request.into_parts(); + assert_eq!(parts.headers["authorization"], "Bearer upstream-video-key"); + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or(json!(null)); + seen.lock().unwrap().push(json!({ + "method": parts.method.as_str(), + "url": parts.uri.path(), + "body": {"json_body": body} + })); + let response = if parts.method == http::Method::POST { + json!({"request_id":"upstream-video-id", "provider_extension":{"accepted":true}}) + } else { + assert_eq!(parts.uri.path(), "/v1/videos/upstream-video-id"); + if calls.fetch_add(1, Ordering::SeqCst) == 0 { + json!({"status":"pending"}) + } else { + json!({"status":"done", "model":"grok-imagine-video", "video":{"url":video_url.lock().unwrap().clone(), "duration":6, "respect_moderation":true}, "provider_extension":"preserved"}) + } + }; + Json(response) + } + } + })) + .route("/test.mp4", any(|request: Request| async move { + assert!(request.headers().get("authorization").is_none()); + assert!(request.headers().get("x-xai-token-auth").is_none()); + ([("content-type", "video/mp4")], "test-video-bytes") + })); + let (runtime_url, runtime_handle) = start_server(runtime).await; + let expected_video_url = format!("{runtime_url}/test.mp4"); + *video_url.lock().unwrap() = expected_video_url.clone(); + let state_factory = || { + let auth = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![ + ( + Some(format!("{:x}", Sha256::digest(b"owner-key"))), + sample_auth_snapshot("owner-api-key", "owner"), + ), + ( + Some(format!("{:x}", Sha256::digest(b"foreign-key"))), + sample_auth_snapshot("foreign-api-key", "foreign"), + ), + ])); + let candidates = Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![ + sample_candidate_row(), + ])); + let catalog = video_provider_catalog_repository_with_proxy( + "provider-openai-video-local-1", + "xai", + "endpoint-openai-video-local-1", + "openai:video", + "http://video-provider.invalid/v1", + "key-openai-video-local-1", + "upstream-video-key", + Some(json!({"enabled":true,"node_id":"video-proxy"})), + ); + AppState::new().expect("gateway should build").with_video_task_truth_source_mode(VideoTaskTruthSourceMode::RustAuthoritative).with_data_state_for_tests( + crate::data::GatewayDataState::with_auth_candidate_selection_provider_catalog_and_request_candidate_repository_for_tests( + auth, candidates, catalog, Arc::new(InMemoryRequestCandidateRepository::default()), DEVELOPMENT_ENCRYPTION_KEY + ).attach_video_task_repository_for_tests(repository.clone()) + .attach_proxy_node_repository_for_tests(video_proxy_node_repository_at_url(["video-proxy"], &runtime_url)) + ) + }; + let router_factory = + || crate::attach_static_frontend(build_router_with_state(state_factory()), &static_dir); + let (gateway_url, gateway_handle) = start_server(router_factory()).await; + let client = reqwest::Client::new(); + assert_eq!( + client + .get(&gateway_url) + .send() + .await + .unwrap() + .text() + .await + .unwrap(), + "Aether test frontend" + ); + for (path, native) in [ + ("/v1/videos/generations", true), + ("/v1/videos", true), + ("/v1/videos/edits", true), + ("/v1/videos/extensions", true), + ("/openai/v1/videos", false), + ] { + calls.store(0, Ordering::SeqCst); + let body = if native { + json!({"model":"video-model","prompt":"A cat","duration":6,"aspect_ratio":"1:1","video":{"url":"https://example.com/input.mp4"},"future_option":true}) + } else { + json!({"model":"video-model","prompt":"A cat","seconds":"6","size":"1280x720"}) + }; + let response = client + .post(format!("{gateway_url}{path}")) + .bearer_auth("owner-key") + .json(&body) + .send() + .await + .unwrap(); + let status = response.status(); + let result: serde_json::Value = response.json().await.unwrap(); + assert_eq!(status, StatusCode::OK, "{path}: {result}"); + let id = result[if native { "request_id" } else { "id" }] + .as_str() + .unwrap(); + assert_ne!(id, "upstream-video-id"); + if native { + assert!(result.get("id").is_none()); + assert_eq!(result["provider_extension"]["accepted"], true); + } else { + assert_eq!(result["status"], "queued"); + } + let request = seen.lock().unwrap().last().unwrap().clone(); + let suffix = if path.ends_with("/edits") { + "edits" + } else if path.ends_with("/extensions") { + "extensions" + } else { + "generations" + }; + assert_eq!(request["url"], format!("/v1/videos/{suffix}")); + assert_eq!(request["body"]["json_body"]["model"], "grok-imagine-video"); + assert_eq!(request["body"]["json_body"]["duration"], 6); + if native { + assert_eq!(request["body"]["json_body"]["future_option"], true); + } else { + assert_eq!(request["body"]["json_body"]["aspect_ratio"], "16:9"); + assert_eq!(request["body"]["json_body"]["resolution"], "720p"); + assert!(request["body"]["json_body"].get("seconds").is_none()); + assert!(request["body"]["json_body"].get("size").is_none()); + } + let query = format!( + "{gateway_url}{}/{id}", + if native { + "/v1/videos" + } else { + "/openai/v1/videos" + } + ); + let before = seen.lock().unwrap().len(); + let denied = client + .get(&query) + .bearer_auth("foreign-key") + .send() + .await + .unwrap(); + assert_eq!(denied.status(), StatusCode::NOT_FOUND); + assert_eq!(seen.lock().unwrap().len(), before); + let denied_content = client + .get(format!("{gateway_url}/openai/v1/videos/{id}/content")) + .bearer_auth("foreign-key") + .send() + .await + .unwrap(); + assert_eq!(denied_content.status(), StatusCode::NOT_FOUND); + assert_eq!(seen.lock().unwrap().len(), before); + let pending: serde_json::Value = client + .get(&query) + .bearer_auth("owner-key") + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!( + pending["status"], + if native { "pending" } else { "queued" }, + "{path}: {pending}" + ); + let done: serde_json::Value = client + .get(&query) + .bearer_auth("owner-key") + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(done["status"], if native { "done" } else { "completed" }); + if native { + assert_eq!(done["video"]["respect_moderation"], true); + assert_eq!(done["provider_extension"], "preserved"); + } else { + assert_eq!(done["video_url"], expected_video_url); + } + let stored = repository + .find(VideoTaskLookupKey::Id(id)) + .await + .unwrap() + .unwrap(); + assert_eq!( + stored.client_api_format.as_deref(), + Some(if native { "xai:video" } else { "openai:video" }) + ); + assert_eq!( + stored.external_task_id.as_deref(), + Some("upstream-video-id") + ); + assert!(stored.request_metadata.is_none()); + assert!(stored.original_request_body.is_none()); + // A new gateway instance must reconstruct the pinned provider/credential and protocol. + let (restart_url, restart_handle) = start_server(router_factory()).await; + let restored: serde_json::Value = client + .get(format!( + "{restart_url}{}/{id}", + if native { + "/v1/videos" + } else { + "/openai/v1/videos" + } + )) + .bearer_auth("owner-key") + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(restored["status"], done["status"]); + if native { + assert_eq!(restored["video"]["respect_moderation"], true); + } + let compat: serde_json::Value = client + .get(format!("{restart_url}/openai/v1/videos/{id}")) + .bearer_auth("owner-key") + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(compat["status"], "completed"); + assert_eq!(compat["video_url"], expected_video_url); + let native_view: serde_json::Value = client + .get(format!("{restart_url}/v1/videos/{id}")) + .bearer_auth("owner-key") + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(native_view["status"], "done"); + assert_eq!(native_view["video"]["respect_moderation"], true); + for prefix in ["/v1/videos", "/openai/v1/videos"] { + let content = client + .get(format!("{restart_url}{prefix}/{id}/content")) + .bearer_auth("owner-key") + .send() + .await + .unwrap(); + assert_eq!(content.status(), StatusCode::OK); + assert_eq!(content.headers()["content-type"], "video/mp4"); + assert_eq!(content.bytes().await.unwrap(), "test-video-bytes"); + } + restart_handle.abort(); + } + let before = seen.lock().unwrap().len(); + let bad = client + .post(format!("{gateway_url}/openai/v1/videos")) + .bearer_auth("owner-key") + .json(&json!({"model":"video-model","prompt":"cat","seconds":"wrong"})) + .send() + .await + .unwrap(); + assert_eq!(bad.status(), StatusCode::BAD_REQUEST); + assert_eq!(seen.lock().unwrap().len(), before); + gateway_handle.abort(); + runtime_handle.abort(); + std::fs::remove_dir_all(&static_dir).unwrap(); +} diff --git a/apps/aether-gateway/src/video_tasks/tests/plans.rs b/apps/aether-gateway/src/video_tasks/tests/plans.rs index e40ec578a..154910309 100644 --- a/apps/aether-gateway/src/video_tasks/tests/plans.rs +++ b/apps/aether-gateway/src/video_tasks/tests/plans.rs @@ -11,6 +11,9 @@ use super::{ fn rust_authoritative_service_builds_openai_cancel_follow_up_plan() { let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative); service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: false, local_task_id: "task-local-123".to_string(), upstream_task_id: "ext-video-task-123".to_string(), created_at_unix_ms: 1712345678, @@ -92,6 +95,9 @@ fn rust_authoritative_service_builds_openai_cancel_follow_up_plan() { fn rust_authoritative_service_builds_openai_remix_follow_up_plan() { let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative); service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: false, local_task_id: "task-local-123".to_string(), upstream_task_id: "ext-video-task-123".to_string(), created_at_unix_ms: 1712345678, @@ -177,6 +183,9 @@ fn rust_authoritative_service_builds_openai_remix_follow_up_plan() { fn rust_authoritative_service_builds_openai_delete_follow_up_plan() { let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative); service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: false, local_task_id: "task-local-123".to_string(), upstream_task_id: "ext-video-task-123".to_string(), created_at_unix_ms: 1712345678, @@ -332,6 +341,9 @@ fn rust_authoritative_service_builds_gemini_cancel_follow_up_plan() { fn rust_authoritative_service_builds_openai_read_refresh_plan() { let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative); service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: false, local_task_id: "task-local-123".to_string(), upstream_task_id: "ext-video-task-123".to_string(), created_at_unix_ms: 1712345678, @@ -407,6 +419,9 @@ fn rust_authoritative_service_builds_gemini_read_refresh_plan() { fn rust_authoritative_service_builds_poll_refresh_batch_for_active_tasks_only() { let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative); service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: false, local_task_id: "task-active-123".to_string(), upstream_task_id: "ext-video-task-123".to_string(), created_at_unix_ms: 1712345678, @@ -428,6 +443,9 @@ fn rust_authoritative_service_builds_poll_refresh_batch_for_active_tasks_only() transport: sample_transport("https://api.openai.example", "openai:video"), })); service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: false, local_task_id: "task-completed-123".to_string(), upstream_task_id: "ext-video-task-999".to_string(), created_at_unix_ms: 1712345678, @@ -471,6 +489,9 @@ fn file_video_task_store_persists_snapshots_across_service_rebuilds() { ) .expect("file-backed service should build"); service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: false, local_task_id: "task-file-123".to_string(), upstream_task_id: "ext-video-task-123".to_string(), created_at_unix_ms: 1712345678, diff --git a/apps/aether-gateway/src/video_tasks/tests/projection.rs b/apps/aether-gateway/src/video_tasks/tests/projection.rs index fa7978bfd..fed35f5d1 100644 --- a/apps/aether-gateway/src/video_tasks/tests/projection.rs +++ b/apps/aether-gateway/src/video_tasks/tests/projection.rs @@ -10,6 +10,9 @@ use super::{ fn rust_authoritative_service_projects_openai_status_into_local_read_response() { let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative); service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: false, local_task_id: "task-local-123".to_string(), upstream_task_id: "ext-video-task-123".to_string(), created_at_unix_ms: 1712345678, @@ -93,6 +96,9 @@ fn rust_authoritative_service_projects_openai_status_into_local_read_response() fn rust_authoritative_service_builds_openai_content_stream_plan_from_direct_video_url() { let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative); service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: false, local_task_id: "task-local-123".to_string(), upstream_task_id: "ext-video-task-123".to_string(), created_at_unix_ms: 1712345678, @@ -159,6 +165,9 @@ fn rust_authoritative_service_builds_openai_content_stream_plan_from_direct_vide fn rust_authoritative_service_returns_processing_content_response_for_pending_openai_task() { let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative); service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: false, local_task_id: "task-local-123".to_string(), upstream_task_id: "ext-video-task-123".to_string(), created_at_unix_ms: 1712345678, diff --git a/apps/aether-gateway/src/video_tasks/tests/sync.rs b/apps/aether-gateway/src/video_tasks/tests/sync.rs index 9634d322a..7d85ef8d8 100644 --- a/apps/aether-gateway/src/video_tasks/tests/sync.rs +++ b/apps/aether-gateway/src/video_tasks/tests/sync.rs @@ -218,6 +218,9 @@ fn rust_authoritative_video_truth_source_can_background_success_report() { fn rust_authoritative_service_reads_openai_task_from_local_registry() { let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative); let snapshot = LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: false, local_task_id: "task-local-123".to_string(), upstream_task_id: "ext-video-task-123".to_string(), created_at_unix_ms: 1712345678, @@ -266,6 +269,9 @@ fn rust_authoritative_service_reads_openai_task_from_local_registry() { fn rust_authoritative_service_applies_cancel_and_delete_mutations() { let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative); service.record_snapshot(LocalVideoTaskSnapshot::OpenAi(OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: false, local_task_id: "task-local-123".to_string(), upstream_task_id: "ext-video-task-123".to_string(), created_at_unix_ms: 1712345678, diff --git a/crates/aether-ai/formats/src/formats/openai/responses/xai.rs b/crates/aether-ai/formats/src/formats/openai/responses/xai.rs index c12b105f4..212f5212d 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/xai.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/xai.rs @@ -872,7 +872,7 @@ mod tests { #[test] fn xai_image_refs_rewrite_openai_aliases_without_touching_chat_parts() { let mut body = json!({ - "model": "grok-4.6", + "model": "grok-imagine-image", "prompt": "edit this", "image": {"image_url": "https://cdn.example/a.png"}, "reference_images": [ @@ -887,7 +887,7 @@ mod tests { }] }); - apply_xai_upstream_payload_edits(&mut body, "xai", "openai:responses"); + apply_xai_upstream_payload_edits(&mut body, "xai", "openai:image"); assert_eq!(body["image"]["url"], "https://cdn.example/a.png"); assert!(body["image"].get("image_url").is_none()); diff --git a/crates/aether-ai/formats/src/formats/shared/routing.rs b/crates/aether-ai/formats/src/formats/shared/routing.rs index 3dec3d5b7..c85aec418 100644 --- a/crates/aether-ai/formats/src/formats/shared/routing.rs +++ b/crates/aether-ai/formats/src/formats/shared/routing.rs @@ -49,6 +49,10 @@ pub fn resolve_execution_runtime_stream_plan_kind_with_client_surface( method: &Method, path: &str, ) -> Option<&'static str> { + let path = path + .strip_prefix("/openai") + .filter(|p| *p == "/v1/videos" || p.starts_with("/v1/videos/")) + .unwrap_or(path); if route_class != Some("ai_public") { return None; } @@ -181,6 +185,10 @@ pub fn resolve_execution_runtime_sync_plan_kind_with_client_surface( method: &Method, path: &str, ) -> Option<&'static str> { + let path = path + .strip_prefix("/openai") + .filter(|p| *p == "/v1/videos" || p.starts_with("/v1/videos/")) + .unwrap_or(path); if route_class != Some("ai_public") { return None; } @@ -206,7 +214,10 @@ pub fn resolve_execution_runtime_sync_plan_kind_with_client_surface( if route_family == Some("openai") && route_kind == Some("video") && *method == Method::POST - && path == "/v1/videos" + && matches!( + path, + "/v1/videos" | "/v1/videos/generations" | "/v1/videos/edits" | "/v1/videos/extensions" + ) { return Some(OPENAI_VIDEO_CREATE_SYNC_PLAN_KIND); } diff --git a/crates/aether-data/adapters/postgres/src/candidate_selection.rs b/crates/aether-data/adapters/postgres/src/candidate_selection.rs index 192026a50..dddec6d1b 100644 --- a/crates/aether-data/adapters/postgres/src/candidate_selection.rs +++ b/crates/aether-data/adapters/postgres/src/candidate_selection.rs @@ -105,7 +105,7 @@ INNER JOIN LATERAL ( OR ( LOWER(BTRIM(p.provider_type)) = 'xai' AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer', 'api_key') - AND LOWER($3) IN ('openai:responses', 'openai:responses:compact') + AND LOWER($3) IN ('openai:responses', 'openai:responses:compact', 'openai:image', 'openai:video') ) OR ( LOWER(BTRIM(p.provider_type)) IN ('gemini_cli', 'antigravity') @@ -196,7 +196,7 @@ WHERE p.is_active = TRUE OR ( LOWER(BTRIM(p.provider_type)) = 'xai' AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer', 'api_key') - AND LOWER($3) IN ('openai:responses', 'openai:responses:compact') + AND LOWER($3) IN ('openai:responses', 'openai:responses:compact', 'openai:image', 'openai:video') ) OR ( LOWER(BTRIM(p.provider_type)) IN ('gemini_cli', 'antigravity') @@ -380,7 +380,7 @@ INNER JOIN LATERAL ( OR ( LOWER(BTRIM(p.provider_type)) = 'xai' AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer', 'api_key') - AND LOWER($4) IN ('openai:responses', 'openai:responses:compact') + AND LOWER($4) IN ('openai:responses', 'openai:responses:compact', 'openai:image', 'openai:video') ) OR ( LOWER(BTRIM(p.provider_type)) IN ('gemini_cli', 'antigravity') @@ -472,7 +472,7 @@ WHERE p.is_active = TRUE OR ( LOWER(BTRIM(p.provider_type)) = 'xai' AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer', 'api_key') - AND LOWER($4) IN ('openai:responses', 'openai:responses:compact') + AND LOWER($4) IN ('openai:responses', 'openai:responses:compact', 'openai:image', 'openai:video') ) OR ( LOWER(BTRIM(p.provider_type)) IN ('gemini_cli', 'antigravity') @@ -656,16 +656,16 @@ WHERE p.is_active = TRUE ) ) ) - OR ( - LOWER(BTRIM(p.provider_type)) = 'grok' - AND LOWER(BTRIM(pak.auth_type)) = 'oauth' - AND LOWER($6) IN ('openai:chat', 'openai:responses', 'claude:messages', 'openai:image') - ) - OR ( - LOWER(BTRIM(p.provider_type)) = 'xai' - AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer', 'api_key') - AND LOWER($6) IN ('openai:responses', 'openai:responses:compact') - ) + OR ( + LOWER(BTRIM(p.provider_type)) = 'grok' + AND LOWER(BTRIM(pak.auth_type)) = 'oauth' + AND LOWER($6) IN ('openai:chat', 'openai:responses', 'claude:messages', 'openai:image') + ) + OR ( + LOWER(BTRIM(p.provider_type)) = 'xai' + AND LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer', 'api_key') + AND LOWER($6) IN ('openai:responses', 'openai:responses:compact', 'openai:image', 'openai:video') + ) OR ( LOWER(BTRIM(p.provider_type)) IN ('gemini_cli', 'antigravity') AND LOWER(BTRIM(pak.auth_type)) = 'oauth' @@ -1758,7 +1758,9 @@ mod tests { ] { assert!(sql.contains("LOWER(BTRIM(p.provider_type)) = 'xai'")); assert!(sql.contains("LOWER(BTRIM(pak.auth_type)) IN ('oauth', 'bearer', 'api_key')")); - assert!(sql.contains("'openai:responses', 'openai:responses:compact'")); + assert!(sql.contains( + "'openai:responses', 'openai:responses:compact', 'openai:image', 'openai:video'" + )); assert!(sql.contains("'xai'")); } } diff --git a/crates/aether-data/runtime/src/repository/candidate_selection/memory.rs b/crates/aether-data/runtime/src/repository/candidate_selection/memory.rs index 3719865c2..cf70cc1bb 100644 --- a/crates/aether-data/runtime/src/repository/candidate_selection/memory.rs +++ b/crates/aether-data/runtime/src/repository/candidate_selection/memory.rs @@ -350,7 +350,10 @@ fn key_auth_channel_matches(row: &StoredMinimalCandidateSelectionRow, api_format matches!(auth_type.as_str(), "oauth" | "bearer" | "api_key") && matches!( api_format.as_str(), - "openai:responses" | "openai:responses:compact" + "openai:responses" + | "openai:responses:compact" + | "openai:image" + | "openai:video" ) } "windsurf" => { @@ -620,6 +623,37 @@ mod tests { assert_eq!(rows[0].global_model_name, "grok-4"); } + #[tokio::test] + async fn includes_xai_oauth_rows_for_image_and_video_models() { + let mut image = sample_row("provider-xai", "openai:image", "grok-imagine-image", 10); + image.provider_type = "xai".to_string(); + image.provider_name = "xai".to_string(); + image.key_auth_type = "oauth".to_string(); + image.key_api_formats = Some(vec!["openai:image".to_string(), "openai:video".to_string()]); + + let mut video = image.clone(); + video.endpoint_id = "endpoint-video".to_string(); + video.endpoint_api_format = "openai:video".to_string(); + video.global_model_name = "grok-imagine-video".to_string(); + video.model_provider_model_name = "grok-imagine-video".to_string(); + + let repository = InMemoryMinimalCandidateSelectionReadRepository::seed(vec![image, video]); + + let image_rows = repository + .list_for_exact_api_format("openai:image") + .await + .expect("list should succeed"); + assert_eq!(image_rows.len(), 1); + assert_eq!(image_rows[0].global_model_name, "grok-imagine-image"); + + let video_rows = repository + .list_for_exact_api_format("openai:video") + .await + .expect("list should succeed"); + assert_eq!(video_rows.len(), 1); + assert_eq!(video_rows[0].global_model_name, "grok-imagine-video"); + } + #[tokio::test] async fn requested_model_filter_respects_endpoint_scoped_default_mapping() { let mut selected = sample_row("provider-1", "openai:chat", "deepseek-v4-pro", 10); diff --git a/crates/aether-model-fetch/src/logic.rs b/crates/aether-model-fetch/src/logic.rs index ebd84e031..270b13ba9 100644 --- a/crates/aether-model-fetch/src/logic.rs +++ b/crates/aether-model-fetch/src/logic.rs @@ -615,6 +615,10 @@ pub fn preset_models_for_provider(provider_type: &str) -> Option> { preset_model("grok-3-mini", "xai", "Grok 3 Mini", "openai:responses"), preset_model("grok-3-mini-fast", "xai", "Grok 3 Mini Fast", "openai:responses"), preset_model("grok-composer-2.5-fast", "xai", "Grok Composer 2.5 Fast", "openai:responses"), + preset_model("grok-imagine-image", "xai", "Grok Imagine Image", "openai:image"), + preset_model("grok-imagine-image-quality", "xai", "Grok Imagine Image Quality", "openai:image"), + preset_model("grok-imagine-video", "xai", "Grok Imagine Video", "openai:video"), + preset_model("grok-imagine-video-1.5", "xai", "Grok Imagine Video 1.5", "openai:video"), ], _ => return None, }; @@ -2010,11 +2014,18 @@ mod tests { "grok-3-mini", "grok-3-mini-fast", "grok-composer-2.5-fast", + "grok-imagine-image", + "grok-imagine-image-quality", + "grok-imagine-video", + "grok-imagine-video-1.5", ] ); assert!(models.iter().all(|model| model["owned_by"] == json!("xai"))); + assert_eq!(models[0]["api_formats"], json!(["openai:responses"])); + assert_eq!(models[10]["api_formats"], json!(["openai:image"])); + assert_eq!(models[12]["api_formats"], json!(["openai:video"])); assert!(models .iter() - .all(|model| model["api_formats"] == json!(["openai:responses"]))); + .any(|model| model["id"] == "grok-imagine-image")); } } diff --git a/crates/aether-provider/transport/src/conversion.rs b/crates/aether-provider/transport/src/conversion.rs index 109e7b47f..421f82557 100644 --- a/crates/aether-provider/transport/src/conversion.rs +++ b/crates/aether-provider/transport/src/conversion.rs @@ -776,18 +776,26 @@ mod tests { &transport, RequestConversionKind::ToOpenAiResponses )); - assert!( - !request_pair_allowed_for_transport( - &transport, - "openai:responses:compact", - "openai:responses" - ), - "compact must not convert onto xAI Responses" - ); + assert!(!request_pair_allowed_for_transport( + &transport, + "openai:image", + "openai:responses" + )); + assert!(!request_pair_allowed_for_transport( + &transport, + "openai:video", + "openai:responses" + )); + for isolated in ["openai:responses:compact", "openai:image", "openai:video"] { + assert!( + !request_pair_allowed_for_transport(&transport, isolated, "openai:responses"), + "{isolated} must not convert onto xAI Responses" + ); + } } #[test] - fn xai_compact_endpoint_is_same_format_only() { + fn xai_compact_and_media_endpoints_are_same_format_only() { let compact = transport_snapshot("xai", "openai:responses:compact", "oauth", true, None); assert!(request_pair_allowed_for_transport( &compact, @@ -809,6 +817,25 @@ mod tests { "{client_api_format} must not convert onto xAI compact" ); } + + for api_format in ["openai:image", "openai:video"] { + let transport = transport_snapshot("xai", api_format, "oauth", true, None); + assert!( + request_pair_allowed_for_transport(&transport, api_format, api_format), + "{api_format} same-format transport should be allowed" + ); + for client_api_format in [ + "openai:chat", + "openai:responses", + "claude:messages", + "gemini:generate_content", + ] { + assert!( + !request_pair_allowed_for_transport(&transport, client_api_format, api_format), + "{client_api_format} must not convert onto {api_format}" + ); + } + } } #[test] diff --git a/crates/aether-provider/transport/src/openai_image/mod.rs b/crates/aether-provider/transport/src/openai_image/mod.rs index 2170611c6..2390d274d 100644 --- a/crates/aether-provider/transport/src/openai_image/mod.rs +++ b/crates/aether-provider/transport/src/openai_image/mod.rs @@ -84,6 +84,11 @@ fn is_dedicated_openai_image_provider(transport: &GatewayProviderTransportSnapsh .trim() .eq_ignore_ascii_case("codex") || is_grok_provider_transport(transport) + || transport + .provider + .provider_type + .trim() + .eq_ignore_ascii_case("xai") } pub fn resolve_openai_image_auth( @@ -92,7 +97,10 @@ pub fn resolve_openai_image_auth( if is_grok_provider_transport(transport) { return resolve_grok_session_auth(transport); } - resolve_local_openai_bearer_auth(transport) + resolve_local_openai_bearer_auth(transport).or_else(|| { + crate::generic_oauth::resolve_local_generic_oauth_transport_authorization(transport) + .map(|value| ("authorization".to_string(), value)) + }) } pub fn build_openai_image_upstream_url( @@ -100,7 +108,11 @@ pub fn build_openai_image_upstream_url( request_path: Option<&str>, request_query: Option<&str>, ) -> String { - build_openai_image_url(&transport.endpoint.base_url, request_path, request_query) + build_openai_image_url( + &crate::xai::resolved_xai_request_base_url(transport, "openai:image"), + request_path, + request_query, + ) } pub fn build_openai_image_headers( @@ -113,6 +125,11 @@ pub fn build_openai_image_headers( &BTreeMap::new(), ); provider_request_headers.insert("content-type".to_string(), "application/json".to_string()); + crate::xai::insert_cli_identity_headers_if_needed( + input.transport, + "openai:image", + &mut provider_request_headers, + ); if let Some(accept) = input.accept { provider_request_headers.insert("accept".to_string(), accept.to_string()); } else { @@ -280,6 +297,48 @@ mod tests { ); } + #[test] + fn xai_oauth_image_uses_cli_proxy() { + let mut transport = sample_transport(); + transport.provider.provider_type = "xai".to_string(); + transport.endpoint.base_url = "https://cli-chat-proxy.grok.com/v1".to_string(); + transport.key.auth_type = "oauth".to_string(); + transport.key.decrypted_auth_config = + Some(r#"{"refresh_token":"rt","using_api":false}"#.to_string()); + + assert_eq!( + openai_image_transport_unsupported_reason(&transport, "openai:image"), + None + ); + assert_eq!( + build_openai_image_upstream_url(&transport, Some("/v1/images/generations"), None), + "https://cli-chat-proxy.grok.com/v1/images/generations" + ); + assert_eq!( + build_openai_image_upstream_url(&transport, Some("/v1/images/edits"), None), + "https://cli-chat-proxy.grok.com/v1/images/edits" + ); + let headers = build_openai_image_headers(ProviderOpenAiImageHeadersInput { + transport: &transport, + headers: &HeaderMap::new(), + auth_header: "authorization", + auth_value: "Bearer test-token", + accept: None, + header_rules: None, + provider_request_body: &json!({"prompt": "A cat"}), + original_request_body: &json!({"prompt": "A cat"}), + }) + .unwrap(); + assert_eq!( + headers.get("x-xai-token-auth").map(String::as_str), + Some("xai-grok-cli") + ); + assert_eq!( + headers.get("authorization").map(String::as_str), + Some("Bearer test-token") + ); + } + #[test] fn codex_is_supported_by_dedicated_openai_image_transport_policy() { let mut transport = sample_transport(); diff --git a/crates/aether-provider/transport/src/provider_types.rs b/crates/aether-provider/transport/src/provider_types.rs index 42e520c39..cac278079 100644 --- a/crates/aether-provider/transport/src/provider_types.rs +++ b/crates/aether-provider/transport/src/provider_types.rs @@ -474,6 +474,18 @@ const XAI_FIXED_PROVIDER_TEMPLATE: FixedProviderTemplate = FixedProviderTemplate custom_path: None, config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS, }, + FixedProviderEndpointTemplate { + item_key: "openai:image", + api_format: "openai:image", + custom_path: None, + config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS, + }, + FixedProviderEndpointTemplate { + item_key: "openai:video", + api_format: "openai:video", + custom_path: None, + config_defaults: EMPTY_ENDPOINT_CONFIG_DEFAULTS, + }, ], runtime_policy: XAI_RUNTIME_POLICY, }; @@ -869,7 +881,7 @@ mod tests { } #[test] - fn xai_fixed_provider_template_exposes_responses_endpoints() { + fn xai_fixed_provider_template_exposes_responses_media_endpoints() { let template = fixed_provider_template("xai").expect("xai template should exist"); assert_eq!(template.provider_type, "xai"); assert_eq!(template.base_url, crate::xai::XAI_CHAT_PROXY_BASE_URL); @@ -880,7 +892,12 @@ mod tests { .iter() .map(|item| item.api_format) .collect::>(), - vec!["openai:responses", "openai:responses:compact"] + vec![ + "openai:responses", + "openai:responses:compact", + "openai:image", + "openai:video" + ] ); let policy = provider_runtime_policy("xai"); diff --git a/crates/aether-provider/transport/src/video/mod.rs b/crates/aether-provider/transport/src/video/mod.rs index 85dfa0cd9..69af710bd 100644 --- a/crates/aether-provider/transport/src/video/mod.rs +++ b/crates/aether-provider/transport/src/video/mod.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use std::fmt; +use aether_contracts::ProxySnapshot; use aether_data_contracts::repository::video_tasks::StoredVideoTask; use aether_video_tasks_core::{ LocalVideoTaskSnapshot, LocalVideoTaskTransport, LocalVideoTaskTransportBridgeInput, @@ -12,11 +13,13 @@ use super::auth::{ build_passthrough_headers_with_auth, resolve_local_gemini_auth, resolve_local_openai_bearer_auth, }; -use super::network::{resolve_transport_execution_timeouts, resolve_transport_profile}; +use super::network::{ + resolve_transport_execution_timeouts, resolve_transport_profile, + resolve_transport_proxy_snapshot, +}; use super::policy::{ local_gemini_transport_unsupported_reason_with_network, - local_standard_transport_unsupported_reason_with_network, supports_local_gemini_transport, - supports_local_standard_transport, + local_standard_transport_unsupported_reason_with_network, }; use super::rules::{ apply_local_body_rules_with_request_headers, apply_local_header_rules_with_request_headers, @@ -32,6 +35,7 @@ pub enum ProviderVideoCreateFamily { #[derive(Clone, Copy)] pub struct ProviderVideoCreateHeadersInput<'a> { + pub transport: &'a GatewayProviderTransportSnapshot, pub headers: &'a http::HeaderMap, pub auth_header: &'a str, pub auth_value: &'a str, @@ -79,6 +83,13 @@ pub trait VideoTaskTransportSnapshotLookup: Send + Sync { endpoint_id: &str, key_id: &str, ) -> Result, String>; + + async fn resolve_video_task_proxy( + &self, + transport: &GatewayProviderTransportSnapshot, + ) -> Option { + resolve_transport_proxy_snapshot(transport) + } } pub fn resolve_local_video_task_transport( @@ -89,13 +100,17 @@ pub fn resolve_local_video_task_transport( let api_format = api_format.trim(); let (auth_header, auth_value) = match api_format { "openai:video" => { - if !supports_local_standard_transport(transport, api_format) { + if local_standard_transport_unsupported_reason_with_network(transport, api_format) + .is_some() + { return None; } - resolve_local_openai_bearer_auth(transport)? + resolve_openai_compatible_video_auth(transport)? } "gemini:video" => { - if !supports_local_gemini_transport(transport, api_format) { + if local_gemini_transport_unsupported_reason_with_network(transport, api_format) + .is_some() + { return None; } resolve_local_gemini_auth(transport)? @@ -103,9 +118,9 @@ pub fn resolve_local_video_task_transport( _ => return None, }; - Some(LocalVideoTaskTransport::from_bridge_input( - LocalVideoTaskTransportBridgeInput { - upstream_base_url: transport.endpoint.base_url.clone(), + let mut resolved = + LocalVideoTaskTransport::from_bridge_input(LocalVideoTaskTransportBridgeInput { + upstream_base_url: crate::xai::resolved_xai_request_base_url(transport, api_format), provider_name: Some(transport.provider.name.clone()), provider_id: transport.provider.id.clone(), endpoint_id: transport.endpoint.id.clone(), @@ -114,11 +129,12 @@ pub fn resolve_local_video_task_transport( auth_value, content_type: Some("application/json".to_string()), model_name, - proxy: None, + proxy: resolve_transport_proxy_snapshot(transport), transport_profile: resolve_transport_profile(transport), timeouts: resolve_transport_execution_timeouts(transport), - }, - )) + }); + crate::xai::insert_cli_identity_headers_if_needed(transport, api_format, &mut resolved.headers); + Some(resolved) } pub fn video_create_transport_unsupported_reason( @@ -141,7 +157,7 @@ pub fn resolve_video_create_auth( family: ProviderVideoCreateFamily, ) -> Option<(String, String)> { match family { - ProviderVideoCreateFamily::OpenAi => resolve_local_openai_bearer_auth(transport), + ProviderVideoCreateFamily::OpenAi => resolve_openai_compatible_video_auth(transport), ProviderVideoCreateFamily::Gemini => resolve_local_gemini_auth(transport), } } @@ -173,6 +189,15 @@ pub fn build_video_create_request_body( Some(provider_request_body) } +fn resolve_openai_compatible_video_auth( + transport: &GatewayProviderTransportSnapshot, +) -> Option<(String, String)> { + resolve_local_openai_bearer_auth(transport).or_else(|| { + crate::generic_oauth::resolve_local_generic_oauth_transport_authorization(transport) + .map(|value| ("authorization".to_string(), value)) + }) +} + pub fn build_video_create_upstream_url( transport: &GatewayProviderTransportSnapshot, request_path: &str, @@ -193,7 +218,13 @@ pub fn build_video_create_upstream_url( ProviderVideoCreateFamily::Gemini => &["key"][..], }; return build_passthrough_path_url( - &transport.endpoint.base_url, + &crate::xai::resolved_xai_request_base_url( + transport, + match family { + ProviderVideoCreateFamily::OpenAi => "openai:video", + ProviderVideoCreateFamily::Gemini => "gemini:video", + }, + ), path, request_query, blocked_keys, @@ -202,8 +233,14 @@ pub fn build_video_create_upstream_url( match family { ProviderVideoCreateFamily::OpenAi => build_passthrough_path_url( - &transport.endpoint.base_url, - openai_video_api_root_request_path(request_path), + &crate::xai::resolved_xai_request_base_url(transport, "openai:video"), + if crate::xai::is_xai_provider_transport(transport) + && matches!(request_path, "/v1/videos" | "/openai/v1/videos") + { + "/videos/generations" + } else { + openai_video_api_root_request_path(request_path) + }, request_query, &[], ), @@ -216,6 +253,7 @@ pub fn build_video_create_upstream_url( } fn openai_video_api_root_request_path(request_path: &str) -> &str { + let request_path = request_path.strip_prefix("/openai").unwrap_or(request_path); if request_path.starts_with("/v1/") { &request_path[3..] } else { @@ -232,6 +270,11 @@ pub fn build_video_create_headers( input.auth_value, &BTreeMap::new(), ); + crate::xai::insert_cli_identity_headers_if_needed( + input.transport, + "openai:video", + &mut provider_request_headers, + ); if !apply_local_header_rules_with_request_headers( &mut provider_request_headers, input.header_rules, @@ -281,16 +324,22 @@ pub async fn reconstruct_local_video_task_snapshot( return Ok(None); }; - let Some(local_transport) = + let Some(mut local_transport) = resolve_local_video_task_transport(&transport, provider_api_format, task.model.clone()) else { return Ok(None); }; - Ok(LocalVideoTaskSnapshot::from_stored_task_with_transport( - task, - local_transport, - )) + // Resolve deployment-managed nodes, system defaults and tunnel affinity just as + // creation does; serialized task metadata intentionally contains no credentials. + local_transport.proxy = lookup.resolve_video_task_proxy(&transport).await; + + let mut snapshot = + LocalVideoTaskSnapshot::from_stored_task_with_transport(task, local_transport); + if let Some(LocalVideoTaskSnapshot::OpenAi(seed)) = &mut snapshot { + seed.xai_provider = crate::xai::is_xai_provider_transport(&transport); + } + Ok(snapshot) } #[cfg(test)] @@ -441,6 +490,46 @@ mod tests { assert_eq!(transport.provider_id, "provider-1"); } + #[tokio::test] + async fn reconstructs_video_with_configured_proxy_and_profile() { + let mut transport = sample_transport("openai:video", "oauth"); + transport.provider.provider_type = "xai".into(); + transport.endpoint.base_url = "https://cli-chat-proxy.grok.com/v1".into(); + transport.provider.proxy = Some(json!({"enabled":true,"url":"http://127.0.0.1:9876"})); + transport.provider.config = Some(json!({"fingerprint":{"transport_profile":{ + "profile_id":"test-video","backend":"reqwest_rustls","http_mode":"auto","pool_scope":"key" + }}})); + transport.key.decrypted_auth_config = Some(r#"{"using_api":false}"#.into()); + let lookup = TestLookup(Some(transport)); + let snapshot = reconstruct_local_video_task_snapshot(&lookup, &sample_stored_video_task()) + .await + .unwrap() + .expect("proxied video must resume after restart"); + let LocalVideoTaskSnapshot::OpenAi(seed) = snapshot else { + panic!("expected OpenAI video") + }; + assert!(seed.xai_provider); + assert_eq!( + seed.transport.proxy.as_ref().unwrap().url.as_deref(), + Some("http://127.0.0.1:9876/") + ); + assert_eq!( + seed.transport + .transport_profile + .as_ref() + .unwrap() + .profile_id, + "test-video" + ); + assert_eq!( + seed.transport + .headers + .get("x-xai-token-auth") + .map(String::as_str), + Some("xai-grok-cli") + ); + } + #[test] fn resolves_gemini_video_transport() { let transport = resolve_local_video_task_transport( @@ -489,6 +578,120 @@ mod tests { assert_eq!(url, "https://api.openai.example/v1/videos?trace=1"); } + #[test] + fn xai_video_create_paths_preserve_auth_hosts_and_custom_endpoints() { + for (auth, base) in [ + ("oauth", "https://cli-chat-proxy.grok.com/v1"), + ("api_key", "https://api.x.ai/v1"), + ] { + let mut transport = sample_transport("openai:video", auth); + transport.provider.provider_type = "xai".into(); + transport.endpoint.base_url = "https://cli-chat-proxy.grok.com/v1".into(); + transport.key.decrypted_auth_config = + (auth == "oauth").then(|| r#"{"using_api":false}"#.into()); + for path in ["/v1/videos", "/openai/v1/videos", "/v1/videos/generations"] { + assert_eq!( + build_video_create_upstream_url( + &transport, + path, + Some("trace=1"), + "grok-imagine-video", + ProviderVideoCreateFamily::OpenAi + ) + .unwrap(), + format!("{base}/videos/generations?trace=1") + ); + } + transport.endpoint.base_url = "https://gateway.example/prefix/v1".into(); + assert_eq!( + build_video_create_upstream_url( + &transport, + "/openai/v1/videos", + None, + "grok-imagine-video", + ProviderVideoCreateFamily::OpenAi + ) + .unwrap(), + "https://gateway.example/prefix/v1/videos/generations" + ); + transport.endpoint.custom_path = Some("/custom/videos/generations".into()); + let url = build_video_create_upstream_url( + &transport, + "/openai/v1/videos", + None, + "grok-imagine-video", + ProviderVideoCreateFamily::OpenAi, + ) + .unwrap(); + assert!(url.ends_with("/custom/videos/generations"), "{url}"); + } + let transport = sample_transport("openai:video", "api_key"); + assert_eq!( + build_video_create_upstream_url( + &transport, + "/openai/v1/videos", + None, + "sora", + ProviderVideoCreateFamily::OpenAi + ), + build_video_create_upstream_url( + &transport, + "/v1/videos", + None, + "sora", + ProviderVideoCreateFamily::OpenAi + ) + ); + } + + #[test] + fn xai_oauth_video_uses_cli_proxy() { + let mut transport = sample_transport("openai:video", "oauth"); + transport.provider.provider_type = "xai".to_string(); + transport.endpoint.base_url = "https://cli-chat-proxy.grok.com/v1".to_string(); + transport.key.decrypted_auth_config = + Some(r#"{"refresh_token":"rt","using_api":false}"#.to_string()); + let url = build_video_create_upstream_url( + &transport, + "/v1/videos/generations", + None, + "grok-imagine-video", + ProviderVideoCreateFamily::OpenAi, + ) + .expect("url should build"); + + assert_eq!(url, "https://cli-chat-proxy.grok.com/v1/videos/generations"); + let headers = build_video_create_headers(ProviderVideoCreateHeadersInput { + transport: &transport, + headers: &http::HeaderMap::new(), + auth_header: "authorization", + auth_value: "Bearer test-token", + header_rules: None, + provider_request_body: &json!({"prompt": "A cat"}), + original_request_body: &json!({"prompt": "A cat"}), + }) + .unwrap(); + assert_eq!( + headers.get("x-xai-token-auth").map(String::as_str), + Some("xai-grok-cli") + ); + + let reconstructed = super::resolve_local_video_task_transport( + &transport, + "openai:video", + Some("grok-imagine-video".into()), + ) + .unwrap(); + assert_eq!( + reconstructed.upstream_base_url, + "https://cli-chat-proxy.grok.com/v1" + ); + assert_eq!( + reconstructed.headers.get("x-xai-token-auth"), + headers.get("x-xai-token-auth") + ); + } + #[test] fn builds_gemini_video_create_url_and_removes_client_key_query() { let transport = sample_transport("gemini:video", "api_key"); @@ -512,6 +715,7 @@ mod tests { let provider_request_body = json!({"prompt": "make a clip"}); let original_request_body = provider_request_body.clone(); let headers = build_video_create_headers(ProviderVideoCreateHeadersInput { + transport: &sample_transport("openai:video", "bearer"), headers: &http::HeaderMap::new(), auth_header: "authorization", auth_value: "Bearer secret", diff --git a/crates/aether-provider/transport/src/xai.rs b/crates/aether-provider/transport/src/xai.rs index 169420d75..5dbeae020 100644 --- a/crates/aether-provider/transport/src/xai.rs +++ b/crates/aether-provider/transport/src/xai.rs @@ -1,3 +1,5 @@ +pub mod video; + use std::collections::BTreeMap; use aether_ai_formats::normalize_api_format_alias; @@ -343,6 +345,39 @@ mod tests { )); } + #[test] + fn media_routing_and_cli_headers_follow_auth_and_base_url() { + for api_format in ["openai:image", "openai:video"] { + for stored in ["", XAI_API_BASE_URL, XAI_CHAT_PROXY_BASE_URL] { + for (auth_type, config, expected) in [ + ( + "oauth", + Some(r#"{"refresh_token":"rt","using_api":false}"#), + XAI_CHAT_PROXY_BASE_URL, + ), + ("oauth", Some(r#"{"using_api":true}"#), XAI_API_BASE_URL), + ("bearer", None, XAI_API_BASE_URL), + ] { + let transport = sample_transport(auth_type, config, stored); + assert_eq!( + resolved_xai_upstream_base_url(&transport, api_format).as_deref(), + Some(expected) + ); + assert_eq!( + should_attach_cli_identity_headers(&transport, api_format), + expected == XAI_CHAT_PROXY_BASE_URL + ); + } + } + let custom = sample_transport("oauth", None, "https://custom.example/v1"); + assert_eq!( + resolved_xai_upstream_base_url(&custom, api_format).as_deref(), + Some("https://custom.example/v1") + ); + assert!(!should_attach_cli_identity_headers(&custom, api_format)); + } + } + #[test] fn bearer_without_refresh_uses_official_api() { let transport = sample_transport("bearer", None, XAI_CHAT_PROXY_BASE_URL); diff --git a/crates/aether-provider/transport/src/xai/video.rs b/crates/aether-provider/transport/src/xai/video.rs new file mode 100644 index 000000000..b715952d2 --- /dev/null +++ b/crates/aether-provider/transport/src/xai/video.rs @@ -0,0 +1,147 @@ +use serde_json::{json, Value}; + +/// Native xAI video requests live under /v1; the OpenAI-compatible adapter under /openai/v1. +pub fn is_native_video_request(provider_type: &str, path: &str) -> bool { + provider_type.trim().eq_ignore_ascii_case("xai") + && matches!( + path, + "/v1/videos" | "/v1/videos/generations" | "/v1/videos/edits" | "/v1/videos/extensions" + ) +} + +pub fn is_explicit_native_video_path(path: &str) -> bool { + matches!( + path, + "/v1/videos/generations" | "/v1/videos/edits" | "/v1/videos/extensions" + ) +} + +/// Convert the OpenAI video request contract to xAI's native contract. +/// Native requests bypass this adapter so provider-specific fields remain intact. +pub fn convert_openai_video_request(body: &Value) -> Result { + let prompt = text(&body["prompt"]).ok_or("prompt is required")?; + let seconds = match &body["seconds"] { + Value::Null => 4, + Value::String(value) if value.trim().is_empty() => 4, + Value::String(value) => value + .trim() + .parse::() + .map_err(|_| "seconds must be an integer")?, + value => value.as_i64().ok_or("seconds must be an integer")?, + } + .clamp(1, 15); + let size = text(&body["size"]).unwrap_or("720x1280"); + let default_ratio = match size { + "720x1280" | "1024x1792" => "9:16", + "1280x720" | "1792x1024" => "16:9", + _ => return Err("size must be one of 720x1280, 1280x720, 1024x1792, or 1792x1024"), + }; + let ratio = match text(&body["aspect_ratio"]) + .unwrap_or("") + .to_ascii_lowercase() + .as_str() + { + "square" | "1:1" => "1:1", + "landscape" | "16:9" => "16:9", + "portrait" | "9:16" => "9:16", + "4:3" => "4:3", + "3:4" => "3:4", + "3:2" => "3:2", + "2:3" => "2:3", + _ => default_ratio, + }; + let resolution = if text(&body["resolution"]).is_some_and(|v| v.eq_ignore_ascii_case("480p")) { + "480p" + } else { + "720p" + }; + if text(&body["input_reference"]["file_id"]).is_some() { + return Err("input_reference.file_id is not supported for xAI video generation; use input_reference.image_url"); + } + let image = text(&body["input_reference"]["image_url"]) + .or_else(|| image_url(&body["image"])) + .or_else(|| text(&body["image_url"])); + let references: Vec<_> = ["reference_images", "reference_image_urls"] + .into_iter() + .filter_map(|key| body[key].as_array()) + .flatten() + .filter_map(image_url) + .map(|url| json!({"url":url})) + .collect(); + if references.len() > 7 { + return Err("reference_images supports at most 7 images on xAI"); + } + if image.is_some() && !references.is_empty() { + return Err("image and reference_images cannot be combined on xAI"); + } + let mut result = json!({"model":body["model"], "prompt":prompt, "duration":seconds, "aspect_ratio":ratio, "resolution":resolution}); + if let Some(url) = image { + result["image"] = json!({"url":url}); + } + if !references.is_empty() { + result["reference_images"] = json!(references); + } + Ok(result) +} + +fn text(value: &Value) -> Option<&str> { + value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +fn image_url(value: &Value) -> Option<&str> { + text(value) + .or_else(|| text(&value["url"])) + .or_else(|| text(&value["image_url"])) + .or_else(|| text(&value["image_url"]["url"])) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn xai_video_compatibility_maps_duration_size_and_references() { + let converted = convert_openai_video_request(&json!({ + "model":"grok-imagine-video", "prompt":"A cat", "seconds":"8", "size":"1280x720", + "reference_images":[{"image_url":{"url":"https://example.com/a.png"}}], + "reference_image_urls":["https://example.com/b.png"] + })) + .unwrap(); + assert_eq!( + converted, + json!({"model":"grok-imagine-video", "prompt":"A cat", "duration":8, + "aspect_ratio":"16:9", "resolution":"720p", "reference_images":[{"url":"https://example.com/a.png"},{"url":"https://example.com/b.png"}]}) + ); + let defaults = convert_openai_video_request(&json!({"prompt":"A cat"})).unwrap(); + assert_eq!(defaults["duration"], 4); + assert_eq!(defaults["aspect_ratio"], "9:16"); + for (seconds, expected) in [(-1, 1), (30, 15)] { + assert_eq!( + convert_openai_video_request(&json!({"prompt":"A cat", "seconds":seconds})) + .unwrap()["duration"], + expected + ); + } + } + + #[test] + fn xai_video_compatibility_validates_requests_and_maps_image_input() { + for invalid in [ + json!({}), + json!({"prompt":"cat","seconds":"1.5"}), + json!({"prompt":"cat","size":"foo"}), + json!({"prompt":"cat","input_reference":{"file_id":"file-1"}}), + json!({"prompt":"cat","image":"https://example.com/a.png","reference_images":["https://example.com/b.png"]}), + json!({"prompt":"cat","reference_images":vec!["https://example.com/a.png";8]}), + ] { + assert!(convert_openai_video_request(&invalid).is_err(), "{invalid}"); + } + let body = convert_openai_video_request(&json!({"prompt":"cat","input_reference":{"image_url":"https://example.com/a.png"},"aspect_ratio":"square","resolution":"480p"})).unwrap(); + assert_eq!(body["image"]["url"], "https://example.com/a.png"); + assert_eq!(body["aspect_ratio"], "1:1"); + assert_eq!(body["resolution"], "480p"); + } +} diff --git a/crates/aether-video-tasks-core/Cargo.toml b/crates/aether-video-tasks-core/Cargo.toml index 25f284b52..f50899619 100644 --- a/crates/aether-video-tasks-core/Cargo.toml +++ b/crates/aether-video-tasks-core/Cargo.toml @@ -12,5 +12,6 @@ aether-data-contracts.workspace = true async-trait.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true url.workspace = true uuid.workspace = true diff --git a/crates/aether-video-tasks-core/src/openai.rs b/crates/aether-video-tasks-core/src/openai.rs index 8b0d657cb..5738f07d1 100644 --- a/crates/aether-video-tasks-core/src/openai.rs +++ b/crates/aether-video-tasks-core/src/openai.rs @@ -43,6 +43,27 @@ pub fn map_openai_stored_task_to_read_response( } fn build_openai_stored_task_body(task: StoredVideoTask, status: VideoTaskStatus) -> Value { + if task.client_api_format.as_deref() == Some("xai:video") { + let mut body = json!({"status":match status { + VideoTaskStatus::Completed => "done", + VideoTaskStatus::Expired => "expired", + VideoTaskStatus::Failed | VideoTaskStatus::Cancelled | VideoTaskStatus::Deleted => "failed", + _ => "pending", + }}); + if let Some(model) = task.model { + body["model"] = json!(model); + } + if let Some(url) = task.video_url { + body["video"] = json!({"url":url}); + if let Some(duration) = task.duration_seconds { + body["video"]["duration"] = json!(duration); + } + } + if status == VideoTaskStatus::Failed { + body["error"] = json!({"code":sanitize_video_task_error_code(task.error_code).unwrap_or_else(|| "unknown".into()),"message":"Video generation failed"}); + } + return body; + } let mut body = json!({ "id": task.id, "object": "video", @@ -57,6 +78,9 @@ fn build_openai_stored_task_body(task: StoredVideoTask, status: VideoTaskStatus) if let Some(prompt) = task.prompt { body["prompt"] = Value::String(prompt); } + if let Some(seconds) = task.duration_seconds { + body["seconds"] = json!(seconds.to_string()); + } if let Some(size) = task.size { body["size"] = Value::String(size); } @@ -91,21 +115,97 @@ fn map_openai_stored_task_status(status: VideoTaskStatus) -> &'static str { } impl OpenAiVideoTaskSeed { + pub fn uses_xai_provider(&self) -> bool { + self.xai_provider || self.is_xai_native() + } + + pub fn is_xai_native(&self) -> bool { + self.persistence.client_api_format == "xai:video" + } + + pub fn native_create_body_json(&self) -> Value { + let mut body = self.native_response.clone().unwrap_or_else(|| json!({})); + body["request_id"] = json!(self.local_task_id); + if body.get("id").is_some() { + body["id"] = json!(self.local_task_id); + } + body + } + + fn native_read_body_json(&self) -> Value { + if let Some(mut body) = self.native_response.clone().filter(|body| { + body.get("status").is_some() + || body.get("error").is_some() + || body.get("code").is_some() + }) { + if body.get("request_id").is_some() { + body["request_id"] = json!(self.local_task_id); + } + if body.get("id").is_some() { + body["id"] = json!(self.local_task_id); + } + return body; + } + let mut body = json!({"status":match self.status { + LocalVideoTaskStatus::Completed => "done", + LocalVideoTaskStatus::Expired => "expired", + LocalVideoTaskStatus::Failed | LocalVideoTaskStatus::Cancelled | LocalVideoTaskStatus::Deleted => "failed", + _ => "pending", + }}); + if let Some(model) = &self.model { + body["model"] = json!(model); + } + if let Some(url) = &self.video_url { + body["video"] = json!({"url":url}); + if let Some(duration) = self.seconds.as_deref().and_then(|v| v.parse::().ok()) { + body["video"]["duration"] = json!(duration); + } + } + if self.error_code.is_some() { + body["error"] = json!({"code":self.error_code,"message":"Video generation failed"}); + } + body + } + pub fn apply_provider_body(&mut self, provider_body: &Map) { + if self.uses_xai_provider() { + self.native_response = Some(Value::Object(provider_body.clone())); + } + let raw_status = provider_body .get("status") .and_then(Value::as_str) .map(str::trim) .unwrap_or_default(); - self.status = match raw_status { - "queued" => LocalVideoTaskStatus::Queued, - "processing" => LocalVideoTaskStatus::Processing, - "completed" => LocalVideoTaskStatus::Completed, - "failed" => LocalVideoTaskStatus::Failed, - "cancelled" => LocalVideoTaskStatus::Cancelled, + // Accept xAI's native lifecycle vocabulary alongside OpenAI's fields. + self.status = match raw_status.to_ascii_lowercase().as_str() { + "queued" | "pending" => LocalVideoTaskStatus::Queued, + "processing" | "in_progress" | "running" => LocalVideoTaskStatus::Processing, + "completed" | "done" | "succeeded" | "success" => LocalVideoTaskStatus::Completed, + "failed" | "error" => LocalVideoTaskStatus::Failed, + "cancelled" | "canceled" => LocalVideoTaskStatus::Cancelled, "expired" => LocalVideoTaskStatus::Expired, _ => LocalVideoTaskStatus::Submitted, }; + let error = provider_body.get("error").filter(|value| !value.is_null()); + let error_code = provider_body + .get("code") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + error + .and_then(|value| value.get("code")) + .and_then(Value::as_str) + }); + // xAI may report a failed job as a 200 response with code/error only. + if (error.is_some() || error_code.is_some()) + && !matches!( + self.status, + LocalVideoTaskStatus::Cancelled | LocalVideoTaskStatus::Expired + ) + { + self.status = LocalVideoTaskStatus::Failed; + } self.progress_percent = provider_body .get("progress") .and_then(Value::as_u64) @@ -117,20 +217,35 @@ impl OpenAiVideoTaskSeed { }); self.completed_at_unix_secs = provider_body.get("completed_at").and_then(Value::as_u64); self.expires_at_unix_secs = provider_body.get("expires_at").and_then(Value::as_u64); - let error = provider_body.get("error").and_then(Value::as_object); - self.error_code = sanitize_video_task_error_code( - error - .and_then(|value| value.get("code")) - .and_then(Value::as_str) - .map(str::to_string), - ); + self.error_code = sanitize_video_task_error_code(error_code.map(str::to_string)); self.error_message = None; self.video_url = provider_body .get("video_url") .or_else(|| provider_body.get("url")) .or_else(|| provider_body.get("result_url")) + .or_else(|| { + provider_body + .get("video") + .and_then(|video| video.get("url")) + }) .and_then(Value::as_str) .map(str::to_string); + if let Some(seconds) = provider_body + .get("seconds") + .or_else(|| { + provider_body + .get("video") + .and_then(|video| video.get("duration")) + }) + .filter(|value| value.is_string() || value.is_number()) + { + self.seconds = Some( + seconds + .as_str() + .map(str::to_string) + .unwrap_or_else(|| seconds.to_string()), + ); + } } pub fn build_content_stream_action( @@ -239,6 +354,9 @@ impl OpenAiVideoTaskSeed { } pub fn client_body_json(&self) -> Value { + if self.is_xai_native() { + return self.native_read_body_json(); + } let mut body = json!({ "id": self.local_task_id, "object": "video", @@ -259,6 +377,9 @@ impl OpenAiVideoTaskSeed { if let Some(seconds) = &self.seconds { body["seconds"] = Value::String(seconds.clone()); } + if let Some(video_url) = &self.video_url { + body["video_url"] = Value::String(video_url.clone()); + } if let Some(remixed_from_video_id) = &self.remixed_from_video_id { body["remixed_from_video_id"] = Value::String(remixed_from_video_id.clone()); } @@ -357,12 +478,20 @@ impl OpenAiVideoTaskSeed { } pub fn build_get_follow_up_plan(&self, trace_id: &str) -> Option { - if !matches!( + let refreshable = matches!( self.status, LocalVideoTaskStatus::Submitted | LocalVideoTaskStatus::Queued | LocalVideoTaskStatus::Processing - ) { + ) || (self.uses_xai_provider() + && self.native_response.is_none() + && matches!( + self.status, + LocalVideoTaskStatus::Completed + | LocalVideoTaskStatus::Failed + | LocalVideoTaskStatus::Expired + )); + if !refreshable { return None; } @@ -573,7 +702,12 @@ impl OpenAiVideoTaskSeed { }; let mut record = UpsertVideoTask { id: self.local_task_id.clone(), - short_id: None, + // The production schema requires a unique, non-null short_id (at most 16 chars). + // Derive it deterministically so repeated capture and legacy snapshot reloads agree. + short_id: Some(self.local_short_id.clone().unwrap_or_else(|| { + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(self.local_task_id.as_bytes()))[..16].to_string() + })), request_id: self.persistence.request_id.clone(), user_id: self.user_id.clone(), api_key_id: self.api_key_id.clone(), @@ -589,7 +723,11 @@ impl OpenAiVideoTaskSeed { model: self.model.clone().or_else(|| Some(String::new())), prompt: self.prompt.clone().or_else(|| Some(String::new())), original_request_body: None, - duration_seconds: request_body_u32(&self.persistence.original_request_body, "seconds"), + duration_seconds: self + .seconds + .as_deref() + .and_then(|value| value.parse().ok()) + .or_else(|| request_body_u32(&self.persistence.original_request_body, "seconds")), resolution: request_body_string(&self.persistence.original_request_body, "resolution"), aspect_ratio: request_body_string( &self.persistence.original_request_body, @@ -697,6 +835,9 @@ mod tests { #[test] fn builds_minimal_openai_persistence_record_without_sensitive_snapshot() { let seed = OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: false, local_task_id: "task-openai-sensitive".to_string(), upstream_task_id: "upstream-openai-sensitive".to_string(), created_at_unix_ms: 1_712_345_678, @@ -747,6 +888,12 @@ mod tests { let record = seed.to_upsert_record(); + let short_id = record + .short_id + .as_deref() + .expect("database short_id is required"); + assert_eq!(short_id.len(), 16); + assert_eq!(seed.to_upsert_record().short_id, record.short_id); assert_eq!(record.error_code.as_deref(), Some("provider_error")); assert!(record.original_request_body.is_none()); assert!(record.progress_message.is_none()); @@ -759,6 +906,8 @@ mod tests { let mut stored = record.into_stored(); stored.status = VideoTaskStatus::Completed; + // Migrated tasks can already have a short ID unrelated to the derived ID. + stored.short_id = Some("legacy-short-id".to_string()); let snapshot = LocalVideoTaskSnapshot::from_stored_task_with_transport(&stored, seed.transport) .expect("stored task should reconstruct with current transport"); @@ -766,6 +915,21 @@ mod tests { panic!("expected OpenAI snapshot"); }; assert_eq!(restored.prompt, stored.prompt); + assert_eq!(restored.to_upsert_record().short_id, stored.short_id); + let mut embedded = stored.clone(); + let mut legacy_snapshot = + serde_json::to_value(LocalVideoTaskSnapshot::OpenAi(restored.clone())).unwrap(); + legacy_snapshot["OpenAi"] + .as_object_mut() + .unwrap() + .remove("local_short_id"); + embedded.request_metadata = Some(json!({"rust_local_snapshot": legacy_snapshot})); + let embedded_snapshot = LocalVideoTaskSnapshot::from_stored_task(&embedded) + .expect("legacy embedded snapshot should hydrate"); + assert_eq!( + embedded_snapshot.to_upsert_record().short_id, + stored.short_id + ); assert_eq!(restored.to_upsert_record().video_url, stored.video_url); let Some(LocalVideoTaskContentAction::StreamPlan(plan)) = restored.build_content_stream_action(None, "trace-download") diff --git a/crates/aether-video-tasks-core/src/path.rs b/crates/aether-video-tasks-core/src/path.rs index c266fd4e0..f04251770 100644 --- a/crates/aether-video-tasks-core/src/path.rs +++ b/crates/aether-video-tasks-core/src/path.rs @@ -8,7 +8,9 @@ use uuid::Uuid; use crate::{LocalVideoTaskRegistryMutation, LocalVideoTaskStatus, VideoTaskTruthSourceMode}; pub fn extract_openai_task_id_from_path(path: &str) -> Option<&str> { - let suffix = path.strip_prefix("/v1/videos/")?; + let suffix = path + .strip_prefix("/v1/videos/") + .or_else(|| path.strip_prefix("/openai/v1/videos/"))?; if suffix.is_empty() || suffix.contains('/') || suffix.ends_with(":cancel") @@ -29,21 +31,27 @@ pub fn extract_gemini_short_id_from_path(path: &str) -> Option<&str> { } pub fn extract_openai_task_id_from_cancel_path(path: &str) -> Option<&str> { - let suffix = path.strip_prefix("/v1/videos/")?; + let suffix = path + .strip_prefix("/v1/videos/") + .or_else(|| path.strip_prefix("/openai/v1/videos/"))?; suffix .strip_suffix("/cancel") .filter(|value| !value.is_empty()) } pub fn extract_openai_task_id_from_remix_path(path: &str) -> Option<&str> { - let suffix = path.strip_prefix("/v1/videos/")?; + let suffix = path + .strip_prefix("/v1/videos/") + .or_else(|| path.strip_prefix("/openai/v1/videos/"))?; suffix .strip_suffix("/remix") .filter(|value| !value.is_empty()) } pub fn extract_openai_task_id_from_content_path(path: &str) -> Option<&str> { - let suffix = path.strip_prefix("/v1/videos/")?; + let suffix = path + .strip_prefix("/v1/videos/") + .or_else(|| path.strip_prefix("/openai/v1/videos/"))?; suffix .strip_suffix("/content") .filter(|value| !value.is_empty()) diff --git a/crates/aether-video-tasks-core/src/read_side.rs b/crates/aether-video-tasks-core/src/read_side.rs index fd8749ce1..003dfcdc1 100644 --- a/crates/aether-video-tasks-core/src/read_side.rs +++ b/crates/aether-video-tasks-core/src/read_side.rs @@ -73,7 +73,7 @@ async fn read_openai_video_task_response( } None => state.find_stored_video_task(lookup).await?, }; - let Some(task) = task else { + let Some(mut task) = task else { return Ok(None); }; @@ -81,6 +81,9 @@ async fn read_openai_video_task_response( return Ok(None); } + if request_path.starts_with("/openai/v1/videos/") { + task.client_api_format = Some("openai:video".into()); + } Ok(Some(map_openai_stored_task_to_read_response(task))) } diff --git a/crates/aether-video-tasks-core/src/service.rs b/crates/aether-video-tasks-core/src/service.rs index c94fc00db..a4ba50545 100644 --- a/crates/aether-video-tasks-core/src/service.rs +++ b/crates/aether-video-tasks-core/src/service.rs @@ -105,13 +105,8 @@ impl VideoTaskService { if self.truth_source_mode != VideoTaskTruthSourceMode::RustAuthoritative { return None; } - match route_family { - Some("openai") => extract_openai_task_id_from_path(request_path) - .and_then(|task_id| self.store.read_openai(task_id)), - Some("gemini") => extract_gemini_short_id_from_path(request_path) - .and_then(|short_id| self.store.read_gemini(short_id)), - _ => None, - } + self.snapshot_for_route(route_family, request_path) + .map(|snapshot| snapshot.read_response_for_path(request_path)) } pub fn read_response_for_user( @@ -126,7 +121,7 @@ impl VideoTaskService { let snapshot = self.snapshot_for_route(route_family, request_path)?; snapshot .belongs_to_user(user_id) - .then(|| snapshot.read_response()) + .then(|| snapshot.read_response_for_path(request_path)) } pub fn snapshot_for_route( diff --git a/crates/aether-video-tasks-core/src/snapshot.rs b/crates/aether-video-tasks-core/src/snapshot.rs index 6aea1cb21..7979dc051 100644 --- a/crates/aether-video-tasks-core/src/snapshot.rs +++ b/crates/aether-video-tasks-core/src/snapshot.rs @@ -29,6 +29,7 @@ impl LocalVideoTaskSnapshot { // contain stale identity fields after a task import or repair. match &mut snapshot { Self::OpenAi(seed) => { + seed.local_short_id = task.short_id.clone(); seed.user_id = task.user_id.clone(); seed.api_key_id = task.api_key_id.clone(); } @@ -51,6 +52,9 @@ impl LocalVideoTaskSnapshot { "openai:video" => { let upstream_task_id = non_empty_owned(task.external_task_id.as_ref())?; Some(Self::OpenAi(OpenAiVideoTaskSeed { + local_short_id: task.short_id.clone(), + native_response: None, + xai_provider: persistence.client_api_format == "xai:video", local_task_id: task.id.clone(), upstream_task_id, created_at_unix_ms: task.created_at_unix_ms, @@ -142,6 +146,19 @@ impl LocalVideoTaskSnapshot { } } + pub fn read_response_for_path(&self, path: &str) -> LocalVideoTaskReadResponse { + if let Self::OpenAi(seed) = self { + let mut seed = seed.clone(); + if path.starts_with("/openai/v1/videos/") { + seed.persistence.client_api_format = "openai:video".to_string(); + } else if path.starts_with("/v1/videos/") && seed.uses_xai_provider() { + seed.persistence.client_api_format = "xai:video".to_string(); + } + return Self::OpenAi(seed).read_response(); + } + self.read_response() + } + pub fn read_response(&self) -> LocalVideoTaskReadResponse { match self { Self::OpenAi(seed) => match seed.status { diff --git a/crates/aether-video-tasks-core/src/sync.rs b/crates/aether-video-tasks-core/src/sync.rs index 2bf49bd57..11ef4b8ce 100644 --- a/crates/aether-video-tasks-core/src/sync.rs +++ b/crates/aether-video-tasks-core/src/sync.rs @@ -19,14 +19,17 @@ impl LocalVideoTaskSeed { ) -> Option { let transport = LocalVideoTaskTransport::from_plan(plan)?; let persistence = LocalVideoTaskPersistence::from_report_context(report_context, plan); - match report_kind { + let mut seed = match report_kind { "openai_video_create_sync_finalize" => { - let upstream_id = provider_body.get("id").and_then(Value::as_str)?.trim(); - if upstream_id.is_empty() { - return None; - } + let upstream_id = openai_video_provider_task_id(provider_body)?; Some(Self::OpenAiCreate(OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: report_context + .get("video_provider_xai") + .and_then(Value::as_bool) + .unwrap_or(false), local_task_id: context_text(report_context, "local_task_id") .unwrap_or_else(|| Uuid::new_v4().to_string()), upstream_task_id: upstream_id.to_string(), @@ -37,8 +40,12 @@ impl LocalVideoTaskSeed { model: context_text(report_context, "model") .or_else(|| request_body_text(report_context, "model")), prompt: request_body_text(report_context, "prompt"), - size: request_body_text(report_context, "size"), - seconds: request_body_text(report_context, "seconds"), + size: context_text(report_context, "video_size") + .or_else(|| request_body_text(report_context, "size")), + seconds: context_u64(report_context, "video_duration") + .map(|v| v.to_string()) + .or_else(|| request_body_text(report_context, "seconds")) + .or_else(|| request_body_text(report_context, "duration")), remixed_from_video_id: None, status: LocalVideoTaskStatus::Submitted, progress_percent: 0, @@ -52,12 +59,15 @@ impl LocalVideoTaskSeed { })) } "openai_video_remix_sync_finalize" => { - let upstream_id = provider_body.get("id").and_then(Value::as_str)?.trim(); - if upstream_id.is_empty() { - return None; - } + let upstream_id = openai_video_provider_task_id(provider_body)?; Some(Self::OpenAiRemix(OpenAiVideoTaskSeed { + local_short_id: None, + native_response: None, + xai_provider: report_context + .get("video_provider_xai") + .and_then(Value::as_bool) + .unwrap_or(false), local_task_id: context_text(report_context, "local_task_id") .unwrap_or_else(|| Uuid::new_v4().to_string()), upstream_task_id: upstream_id.to_string(), @@ -68,8 +78,12 @@ impl LocalVideoTaskSeed { model: context_text(report_context, "model") .or_else(|| request_body_text(report_context, "model")), prompt: request_body_text(report_context, "prompt"), - size: request_body_text(report_context, "size"), - seconds: request_body_text(report_context, "seconds"), + size: context_text(report_context, "video_size") + .or_else(|| request_body_text(report_context, "size")), + seconds: context_u64(report_context, "video_duration") + .map(|v| v.to_string()) + .or_else(|| request_body_text(report_context, "seconds")) + .or_else(|| request_body_text(report_context, "duration")), remixed_from_video_id: context_text(report_context, "task_id") .or_else(|| request_body_text(report_context, "remix_video_id")), status: LocalVideoTaskStatus::Submitted, @@ -110,7 +124,11 @@ impl LocalVideoTaskSeed { })) } _ => None, + }?; + if let Self::OpenAiCreate(task) | Self::OpenAiRemix(task) = &mut seed { + task.apply_provider_body(provider_body); } + Some(seed) } pub fn success_report_kind(&self) -> &'static str { @@ -144,12 +162,28 @@ impl LocalVideoTaskSeed { pub fn client_body_json(&self) -> Value { match self { - Self::OpenAiCreate(seed) | Self::OpenAiRemix(seed) => seed.client_body_json(), + Self::OpenAiCreate(seed) | Self::OpenAiRemix(seed) => { + if seed.is_xai_native() { + seed.native_create_body_json() + } else { + seed.client_body_json() + } + } Self::GeminiCreate(seed) => seed.client_body_json(), } } } +fn openai_video_provider_task_id(body: &Map) -> Option<&str> { + // xAI's OpenAI-compatible video creation returns request_id instead of id. + ["id", "request_id"].into_iter().find_map(|field| { + body.get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + }) +} + impl VideoTaskTruthSourceMode { pub fn prepare_sync_success( self, @@ -353,6 +387,234 @@ mod tests { resolve_local_sync_success_background_report_kind, }; + #[test] + fn xai_native_video_protocol_survives_persistence_and_preserves_provider_fields() { + use crate::{ + LocalVideoTaskContentAction, LocalVideoTaskSnapshot, VideoTaskService, + VideoTaskTruthSourceMode, + }; + let mut plan = + build_internal_finalize_video_plan("native-create", "openai:video", None).unwrap(); + plan.url = "https://api.x.ai/v1/videos/generations".into(); + plan.headers + .insert("authorization".into(), "Bearer test-key".into()); + let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative); + let context = json!({"local_task_id":"native-local", "user_id":"owner", "model":"grok-imagine-video", "video_client_protocol":"xai", "video_duration":6}); + let success = service + .prepare_sync_success( + "openai_video_create_sync_finalize", + json!({"request_id":"native-upstream", "future_field":true}) + .as_object() + .unwrap(), + context.as_object().unwrap(), + &plan, + ) + .unwrap(); + assert_eq!( + success.client_body_json(), + json!({"request_id":"native-local","future_field":true}) + ); + let mut snapshot = success.to_snapshot(); + let body = json!({"status":"done","video":{"url":"https://vidgen.x.ai/video.mp4","duration":6,"respect_moderation":true},"future_field":[1,2]}); + snapshot.apply_provider_body(body.as_object().unwrap()); + assert_eq!(snapshot.read_response().body_json, body); + assert_eq!( + snapshot + .read_response_for_path("/openai/v1/videos/native-local") + .body_json["status"], + "completed" + ); + let LocalVideoTaskSnapshot::OpenAi(seed) = &snapshot else { + panic!("openai task expected") + }; + let Some(LocalVideoTaskContentAction::StreamPlan(download)) = + seed.build_content_stream_action(None, "download") + else { + panic!("download expected") + }; + assert_eq!(download.url, "https://vidgen.x.ai/video.mp4"); + assert!(download.headers.is_empty()); + let stored = snapshot.to_upsert_record().into_stored(); + assert!(stored.request_metadata.is_none()); + assert_eq!(stored.client_api_format.as_deref(), Some("xai:video")); + let restored = LocalVideoTaskSnapshot::from_stored_task_with_transport( + &stored, + seed.transport.clone(), + ) + .unwrap(); + assert_eq!(restored.read_response().body_json["status"], "done"); + service.record_snapshot(restored); + let poll = service + .prepare_read_refresh_sync_plan_for_user( + Some("openai"), + "/v1/videos/native-local", + "owner", + "poll", + ) + .unwrap(); + assert_eq!(poll.plan.url, "https://api.x.ai/v1/videos/native-upstream"); + assert!(service + .prepare_read_refresh_sync_plan_for_user( + Some("openai"), + "/v1/videos/native-local", + "foreign", + "poll" + ) + .is_none()); + assert!(service.apply_read_refresh_projection(&poll, body.as_object().unwrap())); + assert_eq!( + service + .read_response_for_user(Some("openai"), "/v1/videos/native-local", "owner") + .unwrap() + .body_json, + body + ); + } + + #[test] + fn xai_video_lifecycle_creates_polls_persists_and_downloads() { + use crate::{ + LocalVideoTaskContentAction, LocalVideoTaskSnapshot, VideoTaskService, + VideoTaskTruthSourceMode, + }; + for api_root in ["https://cli-chat-proxy.grok.com/v1", "https://api.x.ai/v1"] { + let mut plan = + build_internal_finalize_video_plan("xai-create", "openai:video", None).unwrap(); + plan.url = format!("{api_root}/videos/generations"); + plan.headers + .insert("authorization".into(), "Bearer test-token".into()); + let service = VideoTaskService::new(VideoTaskTruthSourceMode::RustAuthoritative); + let context = json!({"local_task_id": "local-video", "model": "grok-imagine-video", "original_request_body": {"prompt": "A cat", "seconds": "6"}}); + let success = service + .prepare_sync_success( + "openai_video_create_sync_finalize", + json!({"request_id": "xai-request"}).as_object().unwrap(), + context.as_object().unwrap(), + &plan, + ) + .unwrap(); + assert_eq!(success.client_body_json()["id"], "local-video"); + assert_eq!(success.client_body_json()["status"], "queued"); + let snapshot = success.to_snapshot(); + assert_eq!( + snapshot.to_upsert_record().external_task_id.as_deref(), + Some("xai-request") + ); + service.record_snapshot(snapshot.clone()); + let poll = service + .prepare_poll_refresh_plan_for_snapshot(snapshot, "xai-poll") + .unwrap(); + assert_eq!(poll.plan.method, "GET"); + assert_eq!(poll.plan.url, format!("{api_root}/videos/xai-request")); + assert_eq!( + poll.plan.headers.get("authorization"), + plan.headers.get("authorization") + ); + assert!(service.apply_read_refresh_projection( + &poll, + json!({"status": "pending"}).as_object().unwrap() + )); + assert_eq!( + service + .read_response(Some("openai"), "/v1/videos/local-video") + .unwrap() + .body_json["status"], + "queued" + ); + assert!(service.apply_read_refresh_projection(&poll, json!({ + "status": "done", "video": {"url": "https://vidgen.x.ai/result.mp4", "duration": 6} + }).as_object().unwrap())); + let snapshot = service + .snapshot_for_route(Some("openai"), "/v1/videos/local-video") + .unwrap(); + assert!(!snapshot.is_active_for_refresh()); + let record = snapshot.to_upsert_record(); + assert_eq!( + record.status, + aether_data_contracts::repository::video_tasks::VideoTaskStatus::Completed + ); + assert_eq!( + record.video_url.as_deref(), + Some("https://vidgen.x.ai/result.mp4") + ); + assert_eq!(record.duration_seconds, Some(6)); + let response = snapshot.read_response(); + assert_eq!(response.body_json["status"], "completed"); + assert_eq!(response.body_json["progress"], 100); + assert_eq!( + response.body_json["video_url"], + "https://vidgen.x.ai/result.mp4" + ); + let LocalVideoTaskSnapshot::OpenAi(seed) = snapshot else { + panic!("OpenAI video expected") + }; + let Some(LocalVideoTaskContentAction::StreamPlan(download)) = + seed.build_content_stream_action(None, "download") + else { + panic!("download expected") + }; + assert_eq!(download.url, "https://vidgen.x.ai/result.mp4"); + assert!( + download.headers.is_empty(), + "provider credentials must not be sent to the media CDN" + ); + } + } + + #[test] + fn xai_video_errors_are_terminal_even_without_a_status() { + use crate::{LocalVideoTaskSnapshot, VideoTaskTruthSourceMode}; + let mut plan = + build_internal_finalize_video_plan("xai-create", "openai:video", None).unwrap(); + plan.url = "https://cli-chat-proxy.grok.com/v1/videos/generations".into(); + for body in [ + json!({"code": "content_policy_violation", "error": "Rejected"}), + json!({"error": {"code": "content_policy_violation", "message": "Rejected"}}), + json!({"status": "failed", "error": "Rejected"}), + ] { + let mut snapshot = VideoTaskTruthSourceMode::RustAuthoritative + .prepare_sync_success( + "openai_video_create_sync_finalize", + json!({"request_id": "xai-request"}).as_object().unwrap(), + &Default::default(), + &plan, + ) + .unwrap() + .to_snapshot(); + snapshot.apply_provider_body(body.as_object().unwrap()); + assert!(!snapshot.is_active_for_refresh()); + assert_eq!(snapshot.read_response().body_json["status"], "failed"); + let LocalVideoTaskSnapshot::OpenAi(seed) = snapshot else { + panic!("OpenAI video expected") + }; + assert!(seed.error_message.is_none()); + } + } + + #[test] + fn openai_video_id_takes_precedence_over_xai_alias() { + assert_eq!( + super::openai_video_provider_task_id( + json!({"id": "openai-id", "request_id": "trace-id"}) + .as_object() + .unwrap() + ), + Some("openai-id") + ); + assert_eq!( + super::openai_video_provider_task_id( + json!({"id": " ", "request_id": "xai-id"}) + .as_object() + .unwrap() + ), + Some("xai-id") + ); + assert_eq!( + super::openai_video_provider_task_id(json!({"request_id": " "}).as_object().unwrap()), + None + ); + } + #[test] fn builds_local_sync_finalize_read_response_for_supported_video_finalize_kinds() { let delete_response = build_local_sync_finalize_read_response( diff --git a/crates/aether-video-tasks-core/src/transport_domain.rs b/crates/aether-video-tasks-core/src/transport_domain.rs index 9503e0087..9f3b267ae 100644 --- a/crates/aether-video-tasks-core/src/transport_domain.rs +++ b/crates/aether-video-tasks-core/src/transport_domain.rs @@ -71,8 +71,16 @@ impl LocalVideoTaskPersistence { .unwrap_or_else(|| plan.request_id.clone()), username: context_text(report_context, "username"), api_key_name: context_text(report_context, "api_key_name"), - client_api_format: context_text(report_context, "client_api_format") - .unwrap_or_else(|| plan.client_api_format.clone()), + client_api_format: if report_context + .get("video_client_protocol") + .and_then(Value::as_str) + == Some("xai") + { + "xai:video".to_string() + } else { + context_text(report_context, "client_api_format") + .unwrap_or_else(|| plan.client_api_format.clone()) + }, provider_api_format: context_text(report_context, "provider_api_format") .unwrap_or_else(|| plan.provider_api_format.clone()), original_request_body: report_context diff --git a/crates/aether-video-tasks-core/src/types.rs b/crates/aether-video-tasks-core/src/types.rs index 81d8a29f1..69e6b255a 100644 --- a/crates/aether-video-tasks-core/src/types.rs +++ b/crates/aether-video-tasks-core/src/types.rs @@ -201,6 +201,13 @@ pub struct LocalVideoTaskPersistence { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct OpenAiVideoTaskSeed { + /// Preserve existing database identity; older snapshots derive it from the local task ID. + #[serde(default)] + pub local_short_id: Option, + #[serde(default)] + pub native_response: Option, + #[serde(default)] + pub xai_provider: bool, pub local_task_id: String, pub upstream_task_id: String, pub created_at_unix_ms: u64, diff --git a/docs/operations/xai-provider.md b/docs/operations/xai-provider.md index 1bc7a1f35..71aee7f72 100644 --- a/docs/operations/xai-provider.md +++ b/docs/operations/xai-provider.md @@ -43,15 +43,105 @@ Quota refresh reads `/user` and `/billing?format=credits` and stores a structure usage snapshot. A prepaid balance keeps an account selectable after the weekly allowance is exhausted. API-key accounts skip the subscription billing surface. +## Images and videos + +OAuth media requests default to `https://cli-chat-proxy.grok.com/v1`; API-key or +`using_api=true` requests default to `https://api.x.ai/v1`. Explicit custom gateways +are preserved. Compact remains on the official endpoint. CLI identity headers are +applied to media requests and restored when a persisted video task's polling transport +is reconstructed. + +Aether's OpenAI-compatible task parser accepts xAI's `request_id` creation field, +status aliases such as `pending` and `done`, nested `video.url` and `video.duration`, +and failure payloads containing `code` / `error` without a status. Existing OpenAI +`id` takes precedence. The client receives Aether's local task ID; polling uses the +upstream task ID and selected credential. Completed video downloads use the returned +media URL without forwarding provider authentication headers to the media host. + +### Public video protocols + +The xAI provider supports two video surfaces: + +| Operation | xAI native | OpenAI compatible | +| --- | --- | --- | +| Create | `POST /v1/videos/generations` | `POST /openai/v1/videos` | +| Edit / extend | `POST /v1/videos/edits`, `POST /v1/videos/extensions` | — | +| Retrieve | `GET /v1/videos/{request_id}` | `GET /openai/v1/videos/{id}` | +| Download | use the returned `video.url` | `GET /openai/v1/videos/{id}/content` | + +For xAI, `POST /v1/videos` is a native creation alias. Other providers retain +Aether's existing OpenAI-compatible `/v1/videos` behavior. xAI callers using +OpenAI `seconds` / `size` parameters must use `/openai/v1/videos`. The adapter +maps these to numeric `duration`, `aspect_ratio`, and `resolution`; it also adapts +image references. This implementation defaults to 4 seconds, portrait, and 720p, +clamps `duration` to 1-15, and validates inputs. Explicit native requests retain +native parameters and additional provider fields. + +Default xAI creation targets `/videos/generations` on the selected upstream host. +Explicit custom endpoint paths still take precedence. Native generation, editing, +and extension paths only select xAI provider candidates. + +Native creation returns `request_id`; native retrieval preserves `done`, nested +`video.url`, and provider fields such as `respect_moderation`. The identifier is +an opaque Aether task ID so queries remain scoped to the owning user and pinned +to the original upstream task and credential. The explicit `/openai/v1/videos` +surface projects `id`, `completed`, and `video_url`. + +The task row records the native client protocol as `xai:video`, while its provider +transport remains `openai:video`. This survives restart without storing request +bodies or credentials. Raw native responses are cached only in memory; after +reconstruction the gateway refreshes from the original provider to recover its +response fields, including for completed tasks. If refreshing is unavailable, +the stored task still provides the native status and media URL projection. + +OpenAI/xAI task persistence supplies a stable 16-character `short_id`, as required +by the PostgreSQL schema. Existing rows retain their original short ID across +reconstruction, including legacy embedded snapshots. This internal identifier is +separate from the opaque local task ID returned to clients; no schema change or +historical row rewrite is needed. + +Task retrieval and content downloads are admitted by the production GET execution +gate. Reconstructed tasks resolve proxy nodes, system proxy defaults, tunnel affinity, +and transport profiles through the same deployment resolver used for creation; +configured proxy routes must not silently turn into direct requests after restart. + +### Runtime configuration + +Standalone Rust deployments must set +`AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative` and restart the +gateway to enable video task retrieval, polling, and content downloads. The CLI's +legacy default is `python-sync-report`: creation can return a task ID in that mode, +but the local task read/refresh paths are disabled and may return HTTP 503. + +When the gateway also serves the frontend, `/openai/v1/videos` and its subpaths +must bypass the static SPA handler and be mounted as API routes. Otherwise a +successful-looking HTTP 200 response to a video query may contain `text/html` +instead of the task's JSON response. The lifecycle regression includes the static +frontend to cover this production configuration. + ## Regression coverage The format tests cover client and hosted search choices, image-only and mixed tool restrictions, encrypted reasoning replay, image reference rewriting, and unchanged OpenAI replay restrictions. Transport tests cover OAuth/API-key/custom routing and -the fixed-provider endpoint template. OAuth tests cover the device code lifecycle, -token import, and batch import. +media identity headers. Video-task tests exercise creation, polling, terminal +projection, persistence fields, content-download planning, and status-less errors +using local fixtures. They do not make paid generation requests. + +The HTTP regression exercises all native creation paths and the compatibility +prefix through the public router and candidate planner, then checks polling, +cross-user denial, persistence, retrieval from a fresh gateway instance, and downloads +through both prefixes without leaking authorization to the media host. It uses the +real HTTP executor and a managed proxy node backed by a local test server, with no +execution-runtime override. The background poller also has a real HTTP proxy-node +regression, so production method guards and transport reconstruction are exercised. +CI also runs the same HTTP lifecycle with the PostgreSQL repository and the +production column constraints/indexes in an isolated temporary table. This catches +persistence failures that the in-memory repository cannot expose. The test uses +local `initdb`, `postgres`, and `pg_ctl` (already provided by the gateway CI job), +or an explicit `AETHER_TEST_DATABASE_URL` pointing to an isolated test database. ```sh -cargo test -p aether-ai-formats -p aether-provider-transport -p aether-oauth --lib +cargo test -p aether-ai-formats -p aether-provider-transport -p aether-video-tasks-core --lib cargo test -p aether-gateway --lib xai ``` From 01acff077468dcde8271a2efd9b86d420a814ee1 Mon Sep 17 00:00:00 2001 From: hkxiaoyao Date: Tue, 15 Sep 2026 08:13:45 +0800 Subject: [PATCH 10/72] fix(routing): preserve fixed order for streaming chat --- .../standard/openai/chat/plans/stream.rs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans/stream.rs b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans/stream.rs index 0f6ae98c6..c846b8df8 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans/stream.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/openai/chat/plans/stream.rs @@ -1,4 +1,4 @@ -use aether_routing_core::RoutingExecutionPolicy; +use aether_routing_core::{RoutingExecutionPolicy, RoutingSchedulingMode}; use async_trait::async_trait; use std::collections::VecDeque; use tracing::warn; @@ -207,7 +207,12 @@ impl LocalOpenAiChatStreamAttemptSource<'_> { async fn next_raw_attempt_with_target_select( &mut self, ) -> Result, GatewayError> { - let select_window = openai_chat_stream_target_select_window(); + let select_window = openai_chat_stream_target_select_window_for_mode( + self.input + .routing_policy + .as_ref() + .map(|policy| policy.scheduling_mode), + ); if select_window <= 1 { return self.next_raw_attempt_linear().await; } @@ -365,6 +370,15 @@ fn openai_chat_stream_target_select_window() -> usize { .clamp(1, MAX_OPENAI_CHAT_STREAM_TARGET_SELECT_WINDOW) } +fn openai_chat_stream_target_select_window_for_mode( + scheduling_mode: Option, +) -> usize { + if scheduling_mode == Some(RoutingSchedulingMode::FixedOrder) { + return 1; + } + openai_chat_stream_target_select_window() +} + #[derive(Clone, Copy)] struct TargetSelectCandidateIdentity<'a> { provider_id: &'a str, @@ -574,4 +588,14 @@ mod tests { assert_eq!(select_target_index(19, &choices), 1); } + + #[test] + fn fixed_order_disables_stream_target_selection() { + assert_eq!( + openai_chat_stream_target_select_window_for_mode(Some( + RoutingSchedulingMode::FixedOrder, + )), + 1 + ); + } } From a5456cdc38e2ffa90a4e4809d50b76b5f7d86849 Mon Sep 17 00:00:00 2001 From: AAEE86 Date: Tue, 15 Sep 2026 16:12:39 +0800 Subject: [PATCH 11/72] =?UTF-8?q?fix(routing):=20=E6=8C=89=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E9=85=8D=E7=BD=AE=E6=89=80=E9=80=89=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E6=8F=90=E4=BE=9B=E5=95=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ngSchedulingPolicyEditor.providers.spec.ts | 166 ++++++++++++++++++ .../RoutingPriorityPolicyEditor.vue | 4 + .../RoutingSchedulingPolicyEditor.vue | 6 + 3 files changed, 176 insertions(+) create mode 100644 frontend/src/features/routing/__tests__/RoutingSchedulingPolicyEditor.providers.spec.ts diff --git a/frontend/src/features/routing/__tests__/RoutingSchedulingPolicyEditor.providers.spec.ts b/frontend/src/features/routing/__tests__/RoutingSchedulingPolicyEditor.providers.spec.ts new file mode 100644 index 000000000..6b395a406 --- /dev/null +++ b/frontend/src/features/routing/__tests__/RoutingSchedulingPolicyEditor.providers.spec.ts @@ -0,0 +1,166 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createApp, h, nextTick, ref, type App } from 'vue' +import { getProvidersSummary, type ProviderWithEndpointsSummary } from '@/api/endpoints' +import type { GlobalModelResponse } from '@/api/global-models' +import RoutingSchedulingPolicyEditor from '../components/RoutingSchedulingPolicyEditor.vue' +import { + createEmptyRoutingGroupConfig, + getModelPolicy, + type RoutingGroupConfig, +} from '../utils/routingPolicy' +import { createSchedulingPolicy, writeSchedulingPolicies } from '../utils/schedulingPolicies' + +vi.mock('@/api/endpoints', () => ({ getProvidersSummary: vi.fn() })) + +const globalModels = ['a', 'b', 'c'].map(name => ({ + id: `id-${name}`, name: `model-${name}`, display_name: `模型 ${name.toUpperCase()}`, +})) as GlobalModelResponse[] +const providerSources = [ + { id: 'provider-a', name: '提供商 A', global_model_ids: ['id-a'] }, + { id: 'provider-b', name: '提供商 B', global_model_ids: ['id-b'] }, + { id: 'provider-shared', name: '共享提供商', global_model_ids: ['id-a', 'id-b'] }, + { id: 'provider-other', name: '无关提供商', global_model_ids: ['id-other'] }, +].map((provider, index) => ({ + ...provider, + provider_priority: index, + is_active: true, + api_formats: ['openai:chat'], +})) as ProviderWithEndpointsSummary[] +const mounted: Array<{ app: App, root: HTMLElement }> = [] + +function mountEditor(selectedModels?: string[], initialModels = globalModels) { + const initial = createEmptyRoutingGroupConfig() + const config = ref(selectedModels === undefined ? initial : writeSchedulingPolicies(initial, [{ + ...createSchedulingPolicy(initial, 'selected'), + models: selectedModels, + }])) + const models = ref(initialModels) + const root = document.createElement('div') + document.body.appendChild(root) + const app = createApp({ + setup: () => () => h(RoutingSchedulingPolicyEditor, { + config: config.value, + globalModels: models.value, + 'onUpdate:config': (value: RoutingGroupConfig) => { config.value = value }, + }), + }) + app.mount(root) + mounted.push({ app, root }) + return { root, config, models } +} + +function providerNames(root: HTMLElement): string[] { + return [...root.querySelectorAll('[draggable="true"] .font-medium')] + .map(element => element.textContent?.trim() ?? '') +} + +async function clickButton(root: HTMLElement, label: string) { + const button = [...root.querySelectorAll('button')] + .find(element => element.getAttribute('aria-label') === label || element.textContent?.trim() === label) + expect(button, `Missing button: ${label}`).toBeTruthy() + button!.click() + await nextTick() +} + +async function toggleModel(root: HTMLElement, name: string) { + if (!root.querySelector('[aria-label="全局模型选择列表"]')) { + await clickButton(root, '选择适用模型') + } + const checkbox = root.querySelector(`[aria-label="选择模型 ${name}"]`) + expect(checkbox).toBeTruthy() + checkbox!.click() + await nextTick() +} + +beforeEach(() => { + vi.mocked(getProvidersSummary).mockReset() + vi.mocked(getProvidersSummary).mockResolvedValue({ + items: providerSources, total: providerSources.length, page: 1, page_size: 9999, + }) + vi.stubGlobal('ResizeObserver', class { + observe() {} + unobserve() {} + disconnect() {} + }) +}) + +afterEach(() => { + for (const { app, root } of mounted.splice(0)) { + app.unmount() + root.remove() + } + vi.unstubAllGlobals() +}) + +describe('scheduling provider filtering', () => { + it('filters providers after choosing a global model and restores all-model mode', async () => { + const { root } = mountEditor() + await vi.waitFor(() => expect(providerNames(root)).toHaveLength(4)) + await clickButton(root, '区分模型') + expect(providerNames(root)).toEqual([]) + await toggleModel(root, 'model-a') + await vi.waitFor(() => expect(providerNames(root)).toEqual(['提供商 A', '共享提供商'])) + await clickButton(root, '全部模型') + await vi.waitFor(() => expect(providerNames(root)).toHaveLength(4)) + }) + + it('shows the union for multiple models once and updates immediately when deselected', async () => { + const { root } = mountEditor(['model-a']) + await vi.waitFor(() => expect(providerNames(root)).toEqual(['提供商 A', '共享提供商'])) + await toggleModel(root, 'model-b') + expect(providerNames(root)).toEqual(['提供商 A', '提供商 B', '共享提供商']) + await toggleModel(root, 'model-a') + expect(providerNames(root)).toEqual(['提供商 B', '共享提供商']) + expect(getProvidersSummary).toHaveBeenCalledTimes(1) + await toggleModel(root, 'model-b') + expect(providerNames(root)).toEqual([]) + expect(root.textContent).not.toContain('提供商排序') + }) + + it('keeps the shared ranking attached to every selected model after filtering', async () => { + const { root, config } = mountEditor(['model-a', 'model-b']) + await vi.waitFor(() => expect(providerNames(root)).toHaveLength(3)) + const row = [...root.querySelectorAll('[draggable="true"]')] + .find(element => element.textContent?.includes('共享提供商'))! + const input = row.querySelector('input[type="number"]')! + input.value = '7' + input.dispatchEvent(new Event('change', { bubbles: true })) + await nextTick() + for (const model of ['model-a', 'model-b']) { + expect(getModelPolicy(config.value, model).provider_priority_overrides).toEqual({ 'provider-shared': 7 }) + } + expect(getModelPolicy(config.value, '*').provider_priority_overrides).toEqual({}) + expect(providerNames(root)).toEqual(['提供商 A', '提供商 B', '共享提供商']) + expect(getProvidersSummary).toHaveBeenCalledTimes(1) + }) + + it('waits for global model IDs without briefly displaying all providers', async () => { + const { root, models } = mountEditor(['model-a'], []) + await vi.waitFor(() => expect(root.textContent).toContain('暂无 Provider')) + expect(providerNames(root)).toEqual([]) + models.value = globalModels + await nextTick() + expect(providerNames(root)).toEqual(['提供商 A', '共享提供商']) + expect(getProvidersSummary).toHaveBeenCalledTimes(1) + }) + + it.each(['model-c', 'missing-model'])('shows an empty list when no providers match %s', async model => { + const { root } = mountEditor([model]) + await vi.waitFor(() => expect(root.textContent).toContain('暂无 Provider')) + expect(providerNames(root)).toEqual([]) + }) + + it('drops hidden providers from the temporary multiselection when models change', async () => { + const { root } = mountEditor(['model-a', 'model-b']) + await vi.waitFor(() => expect(providerNames(root)).toHaveLength(3)) + await clickButton(root, '多选') + const checkbox = root.querySelector('[aria-label="选择 提供商 A"]')! + checkbox.click() + await nextTick() + expect(checkbox.checked).toBe(true) + await toggleModel(root, 'model-a') + expect(providerNames(root)).toEqual(['提供商 B', '共享提供商']) + await toggleModel(root, 'model-a') + expect(root.querySelector('[aria-label="选择 提供商 A"]')!.checked).toBe(false) + }) +}) diff --git a/frontend/src/features/routing/components/RoutingPriorityPolicyEditor.vue b/frontend/src/features/routing/components/RoutingPriorityPolicyEditor.vue index b202230a8..ba95177bb 100644 --- a/frontend/src/features/routing/components/RoutingPriorityPolicyEditor.vue +++ b/frontend/src/features/routing/components/RoutingPriorityPolicyEditor.vue @@ -369,6 +369,7 @@ const props = defineProps<{ config: RoutingGroupConfig model?: string modelId?: string + providerModelIds?: string[] priorityMode?: RoutingPriorityMode schedulingMode?: RoutingSchedulingMode showPriorityMode?: boolean @@ -445,7 +446,10 @@ const poolProviderIds = computed(() => { const providerRows = computed(() => { const overrides = targetModelPolicy.value.provider_priority_overrides + // 多选模型取提供商并集;空数组表示模型尚未解析,不能回退到全部提供商。 + const modelIds = props.providerModelIds === undefined ? null : new Set(props.providerModelIds) return providers.value + .filter(provider => !modelIds || provider.global_model_ids?.some(id => modelIds.has(id))) .map(provider => ({ id: provider.id, name: provider.name, diff --git a/frontend/src/features/routing/components/RoutingSchedulingPolicyEditor.vue b/frontend/src/features/routing/components/RoutingSchedulingPolicyEditor.vue index cf5ca8174..5b4872410 100644 --- a/frontend/src/features/routing/components/RoutingSchedulingPolicyEditor.vue +++ b/frontend/src/features/routing/components/RoutingSchedulingPolicyEditor.vue @@ -178,6 +178,7 @@
{ : [])) } +function providerModelIds(entry: SchedulingPolicy): string[] { + const names = new Set(entry.models) + return props.globalModels.filter(model => names.has(model.name)).map(model => model.id) +} + function publish(): void { emit('update:config', writeSchedulingPolicies({ ...props.config, From e5ab73bf353500cef47811f8a8056d058e286cb6 Mon Sep 17 00:00:00 2001 From: ZheFox <77232781+zhefox@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:29:28 +0800 Subject: [PATCH 12/72] test(usage): preserve terminal build release notification --- crates/aether-usage/runtime/src/runtime.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/aether-usage/runtime/src/runtime.rs b/crates/aether-usage/runtime/src/runtime.rs index 2eec0db56..94044cafe 100644 --- a/crates/aether-usage/runtime/src/runtime.rs +++ b/crates/aether-usage/runtime/src/runtime.rs @@ -13743,7 +13743,7 @@ mod tests { .iter() .any(|record| record.request_id == blocked_request_id)); - release_build.notify_waiters(); + release_build.notify_one(); timeout(Duration::from_secs(2), async { loop { let snapshot = runtime.metrics_snapshot(); From 03496c46c57a96fb09f6ca8df7b1aa6a13eb481d Mon Sep 17 00:00:00 2001 From: stabey <36232531+stabey@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:23:01 +0800 Subject: [PATCH 13/72] fix(ai-formats): carry OpenRouter reasoning fields through chat conversion OpenRouter reports reasoning under `reasoning` and `reasoning_details` rather than the DeepSeek-style `reasoning_content` this crate recognized. Its streaming reasoning phase sends chunks whose `delta.content` is an empty string, so those chunks were dropped and Responses clients saw nothing after `response.in_progress` until they timed the stream out. The sync aggregator kept only content and tool calls, so a stream downgraded to a sync response lost the reasoning entirely. Read all three spellings through one helper. `reasoning_details` wins because only it carries the block index, and OpenRouter repeats the same text in both fields, so exactly one source is read per object. Entries typed `reasoning.encrypted` carry opaque provider state rather than readable text and are skipped. A change of block index closes the open part so downstream summaries keep the provider's segmentation. Co-Authored-By: Claude Opus 5 --- .../language-server | 1 + .../src/formats/openai/chat/response.rs | 234 ++++++++++++++++-- .../formats/src/formats/openai/chat/stream.rs | 200 ++++++++++++++- .../src/formats/shared/sync_products.rs | 99 ++++++++ 4 files changed, 511 insertions(+), 23 deletions(-) create mode 100755 apps/aether-gateway/.aether-windsurf-binary-test-03d740bc-64f1-49cb-b13d-4e5234e5de31/language-server diff --git a/apps/aether-gateway/.aether-windsurf-binary-test-03d740bc-64f1-49cb-b13d-4e5234e5de31/language-server b/apps/aether-gateway/.aether-windsurf-binary-test-03d740bc-64f1-49cb-b13d-4e5234e5de31/language-server new file mode 100755 index 000000000..f8b18e900 --- /dev/null +++ b/apps/aether-gateway/.aether-windsurf-binary-test-03d740bc-64f1-49cb-b13d-4e5234e5de31/language-server @@ -0,0 +1 @@ +test binary \ No newline at end of file diff --git a/crates/aether-ai/formats/src/formats/openai/chat/response.rs b/crates/aether-ai/formats/src/formats/openai/chat/response.rs index 69d503169..64a6589a2 100644 --- a/crates/aether-ai/formats/src/formats/openai/chat/response.rs +++ b/crates/aether-ai/formats/src/formats/openai/chat/response.rs @@ -1,6 +1,6 @@ use std::collections::BTreeMap; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; use crate::{ formats::context::FormatContext, @@ -13,6 +13,53 @@ use crate::{ }, }; +/// Reasoning text carried by one Chat Completions `message` or streaming +/// `delta`, paired with the provider's reasoning block index where one exists. +/// +/// The field name is not standardized. DeepSeek-style upstreams send +/// `reasoning_content`; OpenRouter sends `reasoning` alongside a structured +/// `reasoning_details` array. OpenRouter repeats the same text in both of its +/// fields, so exactly one source is read per object and `reasoning_details` +/// wins because only it carries the block index. +pub(crate) fn openai_chat_reasoning_texts( + object: &Map, +) -> Vec<(Option, String)> { + if let Some(details) = object.get("reasoning_details").and_then(Value::as_array) { + let texts = details + .iter() + .filter_map(Value::as_object) + .filter_map(|detail| { + // `reasoning.encrypted` carries opaque provider state rather + // than readable text, so it has nothing to hand downstream. + if detail.get("type").and_then(Value::as_str) == Some("reasoning.encrypted") { + return None; + } + let text = detail + .get("text") + .or_else(|| detail.get("summary")) + .and_then(Value::as_str) + .filter(|text| !text.is_empty())?; + let index = detail + .get("index") + .and_then(Value::as_u64) + .map(|index| index as usize); + Some((index, text.to_string())) + }) + .collect::>(); + if !texts.is_empty() { + return texts; + } + } + // A provider may null out one spelling while filling the other, so skip + // past any key that is present but carries no string. + ["reasoning_content", "reasoning"] + .iter() + .find_map(|key| object.get(*key).and_then(Value::as_str)) + .filter(|text| !text.is_empty()) + .map(|text| vec![(None, text.to_string())]) + .unwrap_or_default() +} + pub fn from(body: &Value, _ctx: &FormatContext) -> Option { from_raw(body) } @@ -40,21 +87,18 @@ pub fn from_raw(body_json: &Value) -> Option { .iter() .any(|block| matches!(block, CanonicalContentBlock::Thinking { .. })) { - if let Some(reasoning_content) = message - .get("reasoning_content") - .and_then(Value::as_str) - .filter(|value| !value.trim().is_empty()) - { - content.insert( - 0, - CanonicalContentBlock::Thinking { - text: reasoning_content.to_string(), - signature: None, - encrypted_content: None, - extensions: BTreeMap::new(), - }, - ); - } + let thinking = openai_chat_reasoning_texts(message) + .into_iter() + .map(|(_, text)| text) + .filter(|text| !text.trim().is_empty()) + .map(|text| CanonicalContentBlock::Thinking { + text, + signature: None, + encrypted_content: None, + extensions: BTreeMap::new(), + }) + .collect::>(); + content.splice(0..0, thinking); } let stop_reason = openai_finish_reason_to_canonical(choice.get("finish_reason").and_then(Value::as_str)); @@ -179,3 +223,161 @@ pub fn to_raw(canonical: &CanonicalResponse) -> Value { } response } + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::canonical::CanonicalContentBlock; + + fn thinking_texts(response: &CanonicalResponse) -> Vec { + response + .content + .iter() + .filter_map(|block| match block { + CanonicalContentBlock::Thinking { text, .. } => Some(text.clone()), + _ => None, + }) + .collect() + } + + #[test] + fn openrouter_reasoning_details_become_thinking_blocks() { + let response = from_raw(&json!({ + "id": "gen-openrouter-123", + "model": "stealth/ox-alpha", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "done", + "reasoning": "step onestep two", + "reasoning_details": [ + {"type": "reasoning.text", "text": "step one", "index": 0}, + {"type": "reasoning.text", "text": "step two", "index": 1} + ] + }, + "finish_reason": "stop" + }] + })) + .expect("openrouter response should convert"); + + // `reasoning` repeats the same text the details already carry, so the + // details win and the provider's own segmentation survives. + assert_eq!(thinking_texts(&response), vec!["step one", "step two"]); + } + + #[test] + fn openrouter_reasoning_string_becomes_a_thinking_block() { + let response = from_raw(&json!({ + "id": "gen-openrouter-123", + "model": "stealth/ox-alpha", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "done", + "reasoning": "thought about it" + }, + "finish_reason": "stop" + }] + })) + .expect("openrouter response should convert"); + + assert_eq!(thinking_texts(&response), vec!["thought about it"]); + } + + #[test] + fn deepseek_reasoning_content_still_becomes_a_thinking_block() { + let response = from_raw(&json!({ + "id": "chatcmpl-deepseek", + "model": "deepseek-reasoner", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "42", + "reasoning_content": "let me work it out" + }, + "finish_reason": "stop" + }] + })) + .expect("deepseek response should convert"); + + assert_eq!(thinking_texts(&response), vec!["let me work it out"]); + } + + #[test] + fn deepseek_reasoning_content_wins_over_a_bare_reasoning_field() { + let response = from_raw(&json!({ + "id": "chatcmpl-deepseek", + "model": "deepseek-reasoner", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "42", + "reasoning_content": "the real one", + "reasoning": "the other spelling" + }, + "finish_reason": "stop" + }] + })) + .expect("deepseek response should convert"); + + assert_eq!(thinking_texts(&response), vec!["the real one"]); + } + + #[test] + fn blank_reasoning_content_produces_no_thinking_block() { + let response = from_raw(&json!({ + "id": "chatcmpl-deepseek", + "model": "deepseek-reasoner", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "42", "reasoning_content": " "}, + "finish_reason": "stop" + }] + })) + .expect("deepseek response should convert"); + + assert!(thinking_texts(&response).is_empty()); + } + + #[test] + fn plain_openai_response_without_reasoning_is_unchanged() { + let response = from_raw(&json!({ + "id": "chatcmpl-openai", + "model": "gpt-4o", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "hello"}, + "finish_reason": "stop" + }] + })) + .expect("openai response should convert"); + + assert!(thinking_texts(&response).is_empty()); + } + + #[test] + fn encrypted_reasoning_details_carry_no_thinking_text() { + let response = from_raw(&json!({ + "id": "gen-openrouter-123", + "model": "stealth/ox-alpha", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "done", + "reasoning_details": [ + {"type": "reasoning.encrypted", "data": "b3BhcXVl", "index": 0} + ] + }, + "finish_reason": "stop" + }] + })) + .expect("openrouter response should convert"); + + assert!(thinking_texts(&response).is_empty()); + } +} diff --git a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs index a5a29524e..6f5655fd0 100644 --- a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs +++ b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs @@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; +use crate::formats::openai::chat::response::openai_chat_reasoning_texts; use crate::formats::openai::namespace::NamespaceToolAliases; use crate::formats::openai::responses::{ encode_gemini_tool_signature_carrier_with_direction, openai_responses_message_item_id, @@ -41,6 +42,7 @@ pub struct OpenAIChatProviderState { started: bool, finished: bool, pending_finish_reason: Option, + last_reasoning_index: Option, tool_calls: BTreeMap, } @@ -271,24 +273,39 @@ impl OpenAIChatProviderState { } else if delta.contains_key("content") { recognized_delta = true; } - if let Some(reasoning_content) = delta.get("reasoning_content").and_then(Value::as_str) + if delta.contains_key("reasoning_content") + || delta.contains_key("reasoning_details") + || delta.contains_key("reasoning") { recognized_delta = true; - if !reasoning_content.is_empty() { + for (reasoning_index, text) in openai_chat_reasoning_texts(delta) { self.ensure_started(report_context, &mut out); if !self.terminal_only { let (id, model) = self.identity(report_context); + // A change of reasoning block index closes the part that + // is open, so downstream summaries keep the provider's + // own segmentation instead of collapsing into one + // paragraph. + if let Some(reasoning_index) = reasoning_index { + if self + .last_reasoning_index + .is_some_and(|last| last != reasoning_index) + { + out.push(CanonicalStreamFrame { + id: id.clone(), + model: model.clone(), + event: CanonicalStreamEvent::ReasoningSummaryDone, + }); + } + self.last_reasoning_index = Some(reasoning_index); + } out.push(CanonicalStreamFrame { id, model, - event: CanonicalStreamEvent::ReasoningDelta( - reasoning_content.to_string(), - ), + event: CanonicalStreamEvent::ReasoningDelta(text), }); } } - } else if delta.contains_key("reasoning_content") { - recognized_delta = true; } if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) { @@ -4287,6 +4304,175 @@ mod tests { ))); } + #[test] + fn openai_chat_provider_state_reads_openrouter_reasoning_once() { + let mut state = OpenAIChatProviderState::default(); + let report_context = json!({}); + let frames = state + .push_line( + &report_context, + data_line(json!({ + "id": "gen-openrouter-123", + "model": "stealth/ox-alpha", + "choices": [{ + "index": 0, + "delta": { + "content": "", + "role": "assistant", + "reasoning": " me translate the", + "reasoning_details": [{ + "type": "reasoning.text", + "text": " me translate the", + "format": "unknown", + "index": 0 + }] + }, + "finish_reason": Value::Null + }] + })), + ) + .expect("openrouter reasoning delta should parse"); + + let reasoning = frames + .iter() + .filter_map(|frame| match frame.event { + CanonicalStreamEvent::ReasoningDelta(ref text) => Some(text.as_str()), + _ => None, + }) + .collect::>(); + // `reasoning` and `reasoning_details` repeat the same text, so only one + // of the two may reach the client. + assert_eq!(reasoning, vec![" me translate the"]); + assert!(!frames + .iter() + .any(|frame| matches!(frame.event, CanonicalStreamEvent::UnknownEvent(_)))); + } + + #[test] + fn openai_chat_provider_state_still_reads_deepseek_reasoning_content() { + let mut state = OpenAIChatProviderState::default(); + let report_context = json!({}); + let frames = state + .push_line( + &report_context, + data_line(json!({ + "id": "chatcmpl-deepseek", + "model": "deepseek-reasoner", + "choices": [{ + "index": 0, + "delta": {"role": "assistant", "reasoning_content": "let me work it out"}, + "finish_reason": Value::Null + }] + })), + ) + .expect("deepseek reasoning delta should parse"); + + let reasoning = frames + .iter() + .filter_map(|frame| match frame.event { + CanonicalStreamEvent::ReasoningDelta(ref text) => Some(text.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(reasoning, vec!["let me work it out"]); + // A single reasoning block carries no index, so nothing may close a part. + assert!(!frames + .iter() + .any(|frame| matches!(frame.event, CanonicalStreamEvent::ReasoningSummaryDone))); + assert!(!frames + .iter() + .any(|frame| matches!(frame.event, CanonicalStreamEvent::UnknownEvent(_)))); + } + + #[test] + fn openai_chat_provider_state_splits_reasoning_details_on_block_index() { + let mut state = OpenAIChatProviderState::default(); + let report_context = json!({}); + let reasoning_chunk = |index: u64, text: &str| { + data_line(json!({ + "id": "gen-openrouter-123", + "model": "stealth/ox-alpha", + "choices": [{ + "index": 0, + "delta": { + "content": "", + "role": "assistant", + "reasoning_details": [{ + "type": "reasoning.text", + "text": text, + "index": index + }] + }, + "finish_reason": Value::Null + }] + })) + }; + let mut frames = state + .push_line(&report_context, reasoning_chunk(0, "first")) + .expect("first reasoning block should parse"); + frames.extend( + state + .push_line(&report_context, reasoning_chunk(1, "second")) + .expect("second reasoning block should parse"), + ); + + let reasoning = frames + .iter() + .filter_map(|frame| match frame.event { + CanonicalStreamEvent::ReasoningDelta(ref text) => Some(text.as_str()), + CanonicalStreamEvent::ReasoningSummaryDone => Some(""), + _ => None, + }) + .collect::>(); + assert_eq!(reasoning, vec!["first", "", "second"]); + } + + #[test] + fn openai_chat_reasoning_only_stream_reaches_responses_clients() { + // Regression: OpenRouter streams a long reasoning phase as chunks whose + // `delta.content` is an empty string and whose text sits under + // `reasoning`. Dropping those chunks left Responses clients with + // nothing after `response.in_progress` until they timed the stream out. + let mut state = OpenAIChatProviderState::default(); + let mut emitter = OpenAIResponsesClientEmitter::default(); + let report_context = json!({}); + let mut bytes = Vec::new(); + for piece in ["Let", " me think."] { + let frames = state + .push_line( + &report_context, + data_line(json!({ + "id": "gen-openrouter-123", + "model": "stealth/ox-alpha", + "choices": [{ + "index": 0, + "delta": { + "content": "", + "role": "assistant", + "reasoning": piece, + "reasoning_details": [{ + "type": "reasoning.text", + "text": piece, + "format": "unknown", + "index": 0 + }] + }, + "finish_reason": Value::Null + }] + })), + ) + .expect("reasoning chunk should parse"); + for frame in frames { + bytes.extend(emitter.emit(frame).expect("frame should encode")); + } + } + + let sse = String::from_utf8(bytes).expect("sse should be utf8"); + assert!(sse.contains("event: response.reasoning_summary_text.delta\n")); + assert!(sse.contains("\"delta\":\"Let\"")); + assert!(sse.contains("\"delta\":\" me think.\"")); + } + #[test] fn openai_chat_provider_state_waits_for_real_tool_call_identity() { let mut state = OpenAIChatProviderState::default(); diff --git a/crates/aether-ai/formats/src/formats/shared/sync_products.rs b/crates/aether-ai/formats/src/formats/shared/sync_products.rs index 4a20b159f..91285be78 100644 --- a/crates/aether-ai/formats/src/formats/shared/sync_products.rs +++ b/crates/aether-ai/formats/src/formats/shared/sync_products.rs @@ -26,6 +26,7 @@ use serde_json::{json, Map, Value}; use super::{decode_sync_report_body_base64, AiSurfaceFinalizeError}; use crate::formats::claude::messages::stream::ClaudeProviderState; use crate::formats::gemini::generate_content::stream::GeminiProviderState; +use crate::formats::openai::chat::response::openai_chat_reasoning_texts; use crate::formats::openai::chat::stream::{OpenAIChatProviderState, OpenAIResponsesProviderState}; use crate::formats::shared::model_directives::model_directive_display_model_from_report_context; use crate::formats::shared::response::sanitize_claude_read_tool_inputs; @@ -1827,6 +1828,7 @@ fn apply_report_context_model_fallback(model: &mut String, report_context: &Valu struct OpenAIChatChoiceState { role: Option, content: String, + reasoning: String, finish_reason: Option, tool_calls: BTreeMap, } @@ -2195,6 +2197,9 @@ pub fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option if let Some(content) = delta.get("content").and_then(Value::as_str) { state.content.push_str(content); } + for (_, piece) in openai_chat_reasoning_texts(delta) { + state.reasoning.push_str(&piece); + } if let Some(tool_calls) = delta.get("tool_calls").and_then(Value::as_array) { for tool_call in tool_calls { let Some(tool_call_object) = tool_call.as_object() else { @@ -2254,6 +2259,14 @@ pub fn aggregate_openai_chat_stream_sync_response(body: &[u8]) -> Option "role".to_string(), Value::String(state.role.unwrap_or_else(|| "assistant".to_string())), ); + // Reassemble under the spelling this crate emits for Chat clients; the + // provider's own spelling was already normalized away by the parser. + if !state.reasoning.is_empty() { + message.insert( + "reasoning_content".to_string(), + Value::String(state.reasoning), + ); + } if state.tool_calls.is_empty() { message.insert("content".to_string(), Value::String(state.content)); } else { @@ -4139,6 +4152,92 @@ mod tests { ); } + #[test] + fn aggregates_openai_chat_stream_reasoning_into_sync_body() { + // The aggregator used to keep only `content` and `tool_calls`, so a + // stream downgraded to a sync response lost the reasoning entirely — + // for OpenRouter's `reasoning`/`reasoning_details` and for the + // DeepSeek-style `reasoning_content` alike. + let body = concat!( + "data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"Let me\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"Let me\",\"index\":0}]},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" think.\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" think.\",\"index\":0}]},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Done.\",\"role\":\"assistant\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3}}\n\n", + ); + + let result = aggregate_openai_chat_stream_sync_response(body.as_bytes()) + .expect("openrouter chat stream should aggregate into a sync body"); + + let message = &result["choices"][0]["message"]; + assert_eq!(message["content"], "Done."); + // `reasoning` and `reasoning_details` repeat one another, so the + // reassembled text must not double up. + assert_eq!(message["reasoning_content"], "Let me think."); + assert_eq!(result["choices"][0]["finish_reason"], "stop"); + } + + #[test] + fn aggregates_deepseek_reasoning_content_into_sync_body() { + let body = concat!( + "data: {\"id\":\"chatcmpl-deepseek\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-reasoner\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"reasoning_content\":\"Let me\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-deepseek\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-reasoner\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\" think.\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-deepseek\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-reasoner\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"42\"},\"finish_reason\":\"stop\"}]}\n\n", + ); + + let result = aggregate_openai_chat_stream_sync_response(body.as_bytes()) + .expect("deepseek chat stream should aggregate into a sync body"); + + let message = &result["choices"][0]["message"]; + assert_eq!(message["content"], "42"); + assert_eq!(message["reasoning_content"], "Let me think."); + assert_eq!(result["choices"][0]["finish_reason"], "stop"); + } + + #[test] + fn aggregated_chat_stream_without_reasoning_adds_no_reasoning_key() { + let body = "data: {\"id\":\"chatcmpl-openai\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-4o\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"hi\"},\"finish_reason\":\"stop\"}]}\n\n"; + + let result = aggregate_openai_chat_stream_sync_response(body.as_bytes()) + .expect("plain chat stream should aggregate into a sync body"); + + let message = &result["choices"][0]["message"]; + assert_eq!(message["content"], "hi"); + assert!( + message.get("reasoning_content").is_none(), + "a stream with no reasoning must not gain a reasoning key: {message}" + ); + } + + #[test] + fn aggregated_openai_chat_reasoning_reaches_every_client_format() { + let body = concat!( + "data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"Thinking.\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"gen-openrouter-123\",\"object\":\"chat.completion.chunk\",\"model\":\"stealth/ox-alpha\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Done.\",\"role\":\"assistant\"},\"finish_reason\":\"stop\"}]}\n\n", + ); + let aggregated = aggregate_openai_chat_stream_sync_response(body.as_bytes()) + .expect("openrouter chat stream should aggregate into a sync body"); + let report_context = json!({}); + + for (client_api_format, marker) in [ + ("openai:responses", "\"type\":\"reasoning\""), + ("claude:messages", "\"type\":\"thinking\""), + ("gemini:generate_content", "\"thought\":true"), + ] { + let converted = convert_standard_chat_response( + &aggregated, + "openai:chat", + client_api_format, + &report_context, + ) + .unwrap_or_else(|| panic!("{client_api_format} should convert")); + let encoded = serde_json::to_string(&converted).expect("converted body should encode"); + assert!( + encoded.contains(marker), + "{client_api_format} dropped the reasoning block: {encoded}" + ); + } + } + #[test] fn aggregates_openai_chat_stream_tool_usage_and_finish_into_sync_body() { let body = concat!( From e66dd00b84aae95294cc85d3a1b852cd3dd3c874 Mon Sep 17 00:00:00 2001 From: ZheFox <77232781+zhefox@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:39:00 +0800 Subject: [PATCH 14/72] test(frontend): make cross-tab refresh retry timing deterministic --- .../utils/__tests__/crossTabRefresh.spec.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/frontend/src/utils/__tests__/crossTabRefresh.spec.ts b/frontend/src/utils/__tests__/crossTabRefresh.spec.ts index 9d2e633c5..65df715cb 100644 --- a/frontend/src/utils/__tests__/crossTabRefresh.spec.ts +++ b/frontend/src/utils/__tests__/crossTabRefresh.spec.ts @@ -98,6 +98,9 @@ describe('CrossTabRefreshCoordinator', () => { }) afterEach(() => { + vi.clearAllTimers() + vi.restoreAllMocks() + vi.useRealTimers() localStorage.clear() channelRegistry.clear() }) @@ -139,7 +142,14 @@ describe('CrossTabRefreshCoordinator', () => { second.destroy() }) - it('treats peer failure as a retry hint and verifies the session locally', async () => { + it.each([0, 1])('treats peer failure as a retry hint with %i ms between clock reads', async (clockStepMs) => { + vi.useFakeTimers() + let timestamp = Date.now() + vi.spyOn(Date, 'now').mockImplementation(() => { + const current = timestamp + timestamp += clockStepMs + return current + }) const refreshError = new Error('refresh failed') const firstAttempt = createDeferred() const firstExecutor = vi @@ -161,12 +171,14 @@ describe('CrossTabRefreshCoordinator', () => { await Promise.resolve() const secondRun = second.run(secondExecutor) + const firstOutcome = expect(firstRun).rejects.toThrow('refresh failed') + const secondOutcome = expect(secondRun).resolves.toBe('verified-in-second-tab') firstAttempt.reject(refreshError) - await expect(firstRun).rejects.toThrow('refresh failed') - await expect(secondRun).resolves.toBe('verified-in-second-tab') - expect(firstExecutor.mock.calls.length).toBeGreaterThanOrEqual(2) - expect(firstExecutor.mock.calls.length).toBeLessThanOrEqual(3) + await vi.runAllTimersAsync() + await firstOutcome + await secondOutcome + expect(firstExecutor).toHaveBeenCalledTimes(clockStepMs === 0 ? 3 : 2) expect(secondExecutor).toHaveBeenCalledTimes(1) first.destroy() From fe1723d87c8d7a80a9910af02d16ea9de304e0ea Mon Sep 17 00:00:00 2001 From: ZheFox <77232781+zhefox@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:51:56 +0800 Subject: [PATCH 15/72] fix(responses): bound upstream tool call IDs --- .../src/formats/openai/request_contract.rs | 76 +++++++++++++ .../src/formats/openai/responses/mod.rs | 104 +++++++++++++++++- 2 files changed, 177 insertions(+), 3 deletions(-) diff --git a/crates/aether-ai/formats/src/formats/openai/request_contract.rs b/crates/aether-ai/formats/src/formats/openai/request_contract.rs index b145d7c69..771f8a184 100644 --- a/crates/aether-ai/formats/src/formats/openai/request_contract.rs +++ b/crates/aether-ai/formats/src/formats/openai/request_contract.rs @@ -147,6 +147,9 @@ fn finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_ finalization.provider_api_format, reasoning_replay_policy, ); + if crate::is_openai_responses_family_format(finalization.provider_api_format) { + super::responses::normalize_openai_responses_call_ids(body); + } if finalization .provider_api_format .trim() @@ -235,6 +238,79 @@ mod tests { }; use crate::CodexResponsesModelCapabilities; + #[test] + fn finalization_bounds_responses_call_ids_and_preserves_pairing() { + let long_id = format!("call_{}", "a".repeat(78)); + let original = json!({ + "model": "gpt-5.4", + "input": [ + {"type": "function_call", "call_id": long_id, "name": "lookup", "arguments": "{}"}, + {"type": "function_call_output", "call_id": long_id, "output": "result"} + ] + }); + + for (source_api_format, provider_type, provider_api_format, websocket_continuation) in [ + ("openai:responses", "codex", "openai:responses", false), + ( + "openai:responses", + "codex", + "openai:responses:compact", + false, + ), + ("openai:responses", "openai", "openai:responses", false), + ( + "openai:responses", + "openai", + "openai:responses:compact", + false, + ), + ("openai:responses", "codex", "openai:responses", true), + ("openai:chat", "codex", "openai:responses", false), + ("openai:chat", "openai", "openai:responses", false), + ("claude:messages", "codex", "openai:responses", false), + ("claude:messages", "openai", "openai:responses", false), + ( + "gemini:generate_content", + "codex", + "openai:responses", + false, + ), + ( + "gemini:generate_content", + "openai", + "openai:responses", + false, + ), + ] { + let mut body = original.clone(); + let finalization = OpenAiProviderRequestFinalization { + source_api_format, + provider_api_format, + provider_type, + provider_model: "gpt-5.4", + source_model: "gpt-5.4", + body_rules: None, + upstream_is_stream: false, + require_body_stream_field: true, + }; + if websocket_continuation { + super::finalize_openai_provider_request_with_codex_model_capabilities_and_reasoning_replay_policy_for_websocket_continuation( + &mut body, + finalization, + None, + crate::formats::openai::responses::OpenAiResponsesReasoningReplayPolicy::OpenAiItemIds, + ) + } else { + finalize_openai_provider_request(&mut body, finalization) + } + .expect("request should finalize"); + + let call_id = body["input"][0]["call_id"].as_str().expect("call ID"); + assert!(call_id.len() <= 64, "call ID has {} bytes", call_id.len()); + assert_eq!(body["input"][1]["call_id"], call_id); + } + } + #[test] fn validates_reasoning_and_prompt_cache_against_the_final_provider_model() { let body = json!({ diff --git a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs index cd3c90beb..7e55a848a 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs @@ -1,5 +1,9 @@ -use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _}; +use base64::{ + engine::general_purpose::{STANDARD_NO_PAD, URL_SAFE_NO_PAD}, + Engine as _, +}; use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; pub mod codex; pub(crate) mod history; @@ -219,6 +223,28 @@ pub fn normalize_openai_responses_message_item_ids(body: &mut Value) -> usize { repaired } +pub(crate) fn normalize_openai_responses_call_ids(body: &mut Value) { + let Some(input) = body.get_mut("input") else { + return; + }; + let items = match input { + Value::Array(items) => items.as_mut_slice(), + Value::Object(_) => std::slice::from_mut(input), + _ => return, + }; + for item in items { + let Some(Value::String(call_id)) = item.get_mut("call_id") else { + continue; + }; + if call_id.chars().take(65).count() > 64 { + *call_id = format!( + "call_{}", + URL_SAFE_NO_PAD.encode(Sha256::digest(call_id.as_bytes())) + ); + } + } +} + /// Removes reasoning history items that cannot be replayed against an OpenAI Responses backend. /// /// Reasoning IDs are opaque provider references and must never be repaired by changing their @@ -388,8 +414,9 @@ mod tests { use super::{ decode_gemini_tool_signature_carrier, encode_gemini_tool_signature_carrier_with_direction, - normalize_openai_responses_message_item_ids, openai_responses_message_item_id, - openai_responses_request_operation, openai_responses_synthetic_reasoning_item_id, + normalize_openai_responses_call_ids, normalize_openai_responses_message_item_ids, + openai_responses_message_item_id, openai_responses_request_operation, + openai_responses_synthetic_reasoning_item_id, strip_incompatible_openai_responses_reasoning_items, strip_incompatible_openai_responses_reasoning_items_with_policy, GeminiToolSignatureCarrierDirection, OpenAiResponsesReasoningReplayPolicy, @@ -553,6 +580,77 @@ mod tests { assert_ne!(first, other); } + #[test] + fn normalizes_long_call_ids_stably_without_changing_item_ids_or_payloads() { + let long_id = format!("call_{}", "a".repeat(78)); + let other_id = format!("{long_id}b"); + let arguments = json!({"call_id": long_id}).to_string(); + let mut body = json!({"input": [ + {"type": "function_call", "id": "fc_provider", "call_id": long_id, "name": "lookup", "arguments": arguments}, + {"type": "function_call_output", "call_id": long_id, "output": {"call_id": long_id}}, + {"type": "custom_tool_call", "call_id": other_id, "name": "patch", "input": long_id}, + {"type": "custom_tool_call_output", "call_id": other_id, "output": "done"} + ]}); + + normalize_openai_responses_call_ids(&mut body); + + let first_id = body["input"][0]["call_id"].as_str().expect("first call ID"); + let second_id = body["input"][2]["call_id"] + .as_str() + .expect("second call ID"); + for call_id in [first_id, second_id] { + assert!(call_id.len() <= 64); + assert!(call_id.chars().all( + |character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-') + )); + } + assert_ne!(first_id, second_id); + assert_eq!(body["input"][1]["call_id"], first_id); + assert_eq!(body["input"][3]["call_id"], second_id); + assert_eq!(body["input"][0]["id"], "fc_provider"); + assert_eq!(body["input"][0]["arguments"], arguments); + assert_eq!(body["input"][1]["output"]["call_id"], long_id); + assert_eq!(body["input"][2]["input"], long_id); + + let mut continuation = json!({"input": { + "type": "function_call_output", "call_id": long_id, "output": "later" + }}); + normalize_openai_responses_call_ids(&mut continuation); + assert_eq!(continuation["input"]["call_id"], first_id); + + let once = body.clone(); + normalize_openai_responses_call_ids(&mut body); + assert_eq!(body, once); + } + + #[test] + fn call_id_normalization_preserves_valid_boundaries_and_non_item_data() { + let mut body = json!({"input": [ + {"type": "function_call", "call_id": "call_short"}, + {"type": "function_call", "call_id": "a".repeat(64)}, + {"type": "function_call", "call_id": "\u{00e9}".repeat(64)}, + {"type": "message", "content": [{"call_id": "a".repeat(83)}]}, + {"type": "function_call_output", "call_id": null}, + {"type": "function_call_output", "call_id": 42}, + null + ]}); + let unchanged = body.clone(); + normalize_openai_responses_call_ids(&mut body); + assert_eq!(body, unchanged); + + for input in [json!("text"), json!(null)] { + let mut body = json!({"input": input}); + let unchanged = body.clone(); + normalize_openai_responses_call_ids(&mut body); + assert_eq!(body, unchanged); + } + for call_id in ["a".repeat(65), "\u{00e9}".repeat(65)] { + let mut body = json!({"input": [{"type": "function_call", "call_id": call_id}]}); + normalize_openai_responses_call_ids(&mut body); + assert!(body["input"][0]["call_id"].as_str().expect("call ID").len() <= 64); + } + } + #[test] fn normalizes_legacy_message_ids_but_preserves_valid_ids() { let mut body = json!({ From 5842c7232ecd6cdfa95994da9afaac14f5a8deb0 Mon Sep 17 00:00:00 2001 From: ZheFox <77232781+zhefox@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:10:40 +0800 Subject: [PATCH 16/72] fix(usage): preserve full bodies before queue truncation --- .../src/usage/queries/find_by_id_sql.sql | 4 + .../adapters/postgres/src/usage/tests.rs | 12 ++ crates/aether-usage/runtime/src/queue.rs | 12 +- crates/aether-usage/runtime/src/runtime.rs | 170 +++++++++++++++++- .../concurrency-design-audit-2026-09-09.md | 1 + .../__tests__/body-document-engine.spec.ts | 21 ++- 6 files changed, 216 insertions(+), 4 deletions(-) diff --git a/crates/aether-data/adapters/postgres/src/usage/queries/find_by_id_sql.sql b/crates/aether-data/adapters/postgres/src/usage/queries/find_by_id_sql.sql index a5252794e..298d4909e 100644 --- a/crates/aether-data/adapters/postgres/src/usage/queries/find_by_id_sql.sql +++ b/crates/aether-data/adapters/postgres/src/usage/queries/find_by_id_sql.sql @@ -185,6 +185,10 @@ SELECT usage_http_audits.provider_request_body_ref AS http_provider_request_body_ref, usage_http_audits.response_body_ref AS http_response_body_ref, usage_http_audits.client_response_body_ref AS http_client_response_body_ref, + usage_http_audits.request_body_state AS http_request_body_state, + usage_http_audits.provider_request_body_state AS http_provider_request_body_state, + usage_http_audits.response_body_state AS http_response_body_state, + usage_http_audits.client_response_body_state AS http_client_response_body_state, usage_routing_snapshots.candidate_id AS routing_candidate_id, usage_routing_snapshots.candidate_index AS routing_candidate_index, usage_routing_snapshots.key_name AS routing_key_name, diff --git a/crates/aether-data/adapters/postgres/src/usage/tests.rs b/crates/aether-data/adapters/postgres/src/usage/tests.rs index 082765ae7..a1cef6095 100644 --- a/crates/aether-data/adapters/postgres/src/usage/tests.rs +++ b/crates/aether-data/adapters/postgres/src/usage/tests.rs @@ -3467,6 +3467,18 @@ fn usage_sql_reads_http_audits_for_single_record_fetches() { assert!(super::FIND_BY_ID_SQL.contains("LEFT JOIN usage_http_audits")); assert!(super::FIND_BY_REQUEST_ID_SQL.contains("http_request_body_ref")); assert!(super::FIND_BY_ID_SQL.contains("http_client_response_body_ref")); + for sql in [super::FIND_BY_REQUEST_ID_SQL, super::FIND_BY_ID_SQL] { + for field in [ + "request_body", + "provider_request_body", + "response_body", + "client_response_body", + ] { + assert!(sql.contains(&format!( + "usage_http_audits.{field}_state AS http_{field}_state" + ))); + } + } } #[test] diff --git a/crates/aether-usage/runtime/src/queue.rs b/crates/aether-usage/runtime/src/queue.rs index 23b6dadb6..04d8fe6fe 100644 --- a/crates/aether-usage/runtime/src/queue.rs +++ b/crates/aether-usage/runtime/src/queue.rs @@ -104,6 +104,13 @@ impl UsageQueue { pub async fn enqueue(&self, event: &UsageEvent) -> Result { let encoded = self.encode_event(event)?; + self.enqueue_encoded(encoded).await + } + + pub(crate) async fn enqueue_encoded( + &self, + encoded: EncodedUsageEvent, + ) -> Result { self.runner .append_fields_with_maxlen( &self.stream, @@ -117,7 +124,10 @@ impl UsageQueue { self.encode_event(event).map(|_| ()) } - fn encode_event(&self, event: &UsageEvent) -> Result { + pub(crate) fn encode_event( + &self, + event: &UsageEvent, + ) -> Result { let encoded = match event.to_bounded_stream_fields(self.config.queue_payload_max_bytes) { Ok(encoded) => encoded, Err(error) => { diff --git a/crates/aether-usage/runtime/src/runtime.rs b/crates/aether-usage/runtime/src/runtime.rs index 94044cafe..99706f638 100644 --- a/crates/aether-usage/runtime/src/runtime.rs +++ b/crates/aether-usage/runtime/src/runtime.rs @@ -4919,7 +4919,7 @@ impl UsageRuntime { &self, data: &T, queue: UsageQueue, - event: UsageEvent, + mut event: UsageEvent, ) -> TerminalPersistenceOutcome where T: UsageRuntimeAccess, @@ -4952,7 +4952,20 @@ impl UsageRuntime { .await; }; - if let Err(err) = queue.enqueue(&event).await { + let enqueue_result = match queue.encode_event(&event) { + Ok(encoded) => { + if encoded.diagnostics_omitted + && self + .try_write_terminal_direct_fallback(data, &mut event, "queue_wire_limit") + .await + { + return TerminalPersistenceOutcome::PersistedDirectly; + } + queue.enqueue_encoded(encoded).await + } + Err(err) => Err(err), + }; + if let Err(err) = enqueue_result { drop(_guard); if is_permanent_enqueue_error(&err) { return self @@ -12725,6 +12738,159 @@ mod tests { assert_eq!(queued.data.total_cost_usd, None); } + fn oversized_full_terminal_event(max_bytes: usize) -> (serde_json::Value, UsageEvent) { + let body = json!({"content": "full-body".repeat(max_bytes / 16)}); + let mut event = UsageEvent::new( + UsageEventType::Completed, + "oversized-full-capture", + UsageEventData { + provider_name: "openai".to_string(), + model: "gpt-5".to_string(), + status_code: Some(200), + total_tokens: Some(12), + request_body: Some(body.clone()), + provider_request_body: Some(body.clone()), + response_body: Some(body.clone()), + client_response_body: Some(body.clone()), + ..UsageEventData::default() + }, + ); + apply_usage_body_capture_policy_to_event( + UsageBodyCapturePolicy { + record_level: UsageRequestRecordLevel::Full, + }, + &mut event, + ); + (body, event) + } + + #[tokio::test] + async fn oversized_full_terminal_capture_is_persisted_without_queue_truncation() { + let config = UsageRuntimeConfig { + enabled: true, + queue_terminal_events: true, + consumer_block_ms: 1, + ..UsageRuntimeConfig::default() + }; + let queue_runner: Arc = + Arc::new(RuntimeState::memory(MemoryRuntimeStateConfig::default())); + let queue = UsageQueue::new(Arc::clone(&queue_runner), config.clone()).unwrap(); + let store = EnrichmentCountingQueueStore { + records: Mutex::new(Vec::new()), + queue: queue_runner, + enrich_calls: AtomicUsize::new(0), + }; + let runtime = UsageRuntime::new(config.clone()).unwrap(); + let (body, event) = oversized_full_terminal_event(config.queue_payload_max_bytes); + assert!( + event + .to_bounded_stream_fields(config.queue_payload_max_bytes) + .unwrap() + .diagnostics_omitted + ); + + let outcome = runtime.enqueue_or_write_terminal(&store, event).await; + + assert_eq!( + outcome, + super::TerminalPersistenceOutcome::PersistedDirectly + ); + assert_eq!(store.enrich_calls.load(Ordering::Acquire), 1); + assert_eq!(queue.stats().await.unwrap().stream_length, 0); + let records = store.records.lock().unwrap(); + assert_eq!(records.len(), 1); + for captured in [ + &records[0].request_body, + &records[0].provider_request_body, + &records[0].response_body, + &records[0].client_response_body, + ] { + assert_eq!(captured.as_ref(), Some(&body)); + } + } + + #[tokio::test] + async fn oversized_full_terminal_capture_keeps_bounded_queue_fallback() { + for unavailable in ["writer", "write_failure", "worker_gate", "fallback_gate"] { + let config = UsageRuntimeConfig { + enabled: true, + queue_terminal_events: true, + consumer_block_ms: 1, + worker_record_concurrency_limit: Some(1), + ..UsageRuntimeConfig::default() + }; + let queue_runner: Arc = + Arc::new(RuntimeState::memory(MemoryRuntimeStateConfig::default())); + let queue = UsageQueue::new(Arc::clone(&queue_runner), config.clone()).unwrap(); + queue.ensure_consumer_group().await.unwrap(); + let store = FailingWriteQueueConfiguredUsageStore { + queue: Arc::clone(&queue_runner), + upsert_attempts: Arc::new(AtomicUsize::new(0)), + }; + let queue_only = QueueOnlyUsageStore { + queue: queue_runner, + upsert_attempts: Arc::clone(&store.upsert_attempts), + }; + let runtime = UsageRuntime::new(config.clone()).unwrap(); + let worker_permit = (unavailable == "worker_gate").then(|| { + runtime + .worker_record_gate + .as_ref() + .unwrap() + .try_acquire() + .unwrap() + }); + let fallback_permit = (unavailable == "fallback_gate").then(|| { + runtime + .terminal_direct_fallback_state + .try_acquire() + .unwrap() + }); + let (_, event) = oversized_full_terminal_event(config.queue_payload_max_bytes); + + let outcome = if unavailable == "writer" { + runtime.enqueue_or_write_terminal(&queue_only, event).await + } else { + runtime.enqueue_or_write_terminal(&store, event).await + }; + + assert_eq!( + outcome, + super::TerminalPersistenceOutcome::Queued, + "{unavailable}" + ); + assert_eq!( + store.upsert_attempts.load(Ordering::Acquire), + usize::from(unavailable == "write_failure") + ); + let entries = queue + .read_group("oversized-capture-consumer") + .await + .unwrap(); + assert_eq!(entries.len(), 1); + assert!(entries[0].fields["payload"].len() <= config.queue_payload_max_bytes); + let queued = UsageEvent::from_stream_fields(&entries[0].fields).unwrap(); + assert_eq!(queued.data.total_tokens, Some(12)); + for (body, state) in [ + (&queued.data.request_body, queued.data.request_body_state), + ( + &queued.data.provider_request_body, + queued.data.provider_request_body_state, + ), + (&queued.data.response_body, queued.data.response_body_state), + ( + &queued.data.client_response_body, + queued.data.client_response_body_state, + ), + ] { + assert!(body.is_none()); + assert_eq!(state, Some(UsageBodyCaptureState::Truncated)); + } + assert_eq!(runtime.metrics_snapshot().terminal_enqueue_failed_total, 0); + drop((worker_permit, fallback_permit)); + } + } + #[tokio::test] async fn terminal_enqueue_failure_uses_bounded_direct_database_fallback() { let config = UsageRuntimeConfig { diff --git a/docs/operations/concurrency-design-audit-2026-09-09.md b/docs/operations/concurrency-design-audit-2026-09-09.md index a81109632..9c68c95af 100644 --- a/docs/operations/concurrency-design-audit-2026-09-09.md +++ b/docs/operations/concurrency-design-audit-2026-09-09.md @@ -184,6 +184,7 @@ ## 第九轮修复状态 - **新增队列消息的完整字节上限:** `AETHER_GATEWAY_USAGE_QUEUE_PAYLOAD_MAX_BYTES` 默认 1 MiB,启用 usage runtime 时显式 `0` 非法。`UsageQueue::enqueue` 在发送 Redis 命令前,以有界 writer 编码完整 v1 JSON envelope,包含 UTF-8、转义、metadata、正文、headers 及其他字段;不会先生成无限制的完整 JSON 字符串再检查长度。普通消息保持原格式,原公开编码接口及历史消息解码继续兼容。 +- **FULL 终态正文保留补充:** 正常终态入队发现需要剥离诊断正文时,先用原事件尝试已有的受限数据库直写,成功后不再入队降级副本。该路径仍遵守数据库压力检查、共享写入并发门限和直写门限,不提高队列字节上限;仅队列节点、写入失败或门限饱和时仍允许有界降级,保留计费事实及 `Truncated` 状态。按使用记录 ID 读取详情同时返回四类正文的采集状态,避免将已知截断误报为 `legacy_unknown`。已经丢弃的历史正文无法由此恢复。 - **诊断降级保留计费语义:** 完整消息超限后,先借用检查去掉四份正文和四份 headers 的核心字段大小,核心可容纳才克隆 metadata 并生成诊断投影;复用同一字节缓冲。保留 token、费用、显式零、错误存在性、身份、时间、终态、正文引用、预留 token 和计费维度。按完整 v1 消费者规则保留请求档位、推理参数、响应实际档位及缓存 TTL,并标记被移除的正文为 `Truncated`。显式 `None`、`Disabled`、`Unavailable` 不改为可回退的状态;JSON null 与非对象正文分别按旧解码和权威规则处理。 - **无法安全编码时的失败路径:** 核心仍超限,或去掉正文无法保留原缓存 TTL 计费语义时,返回 `InvalidInput`。终态沿现有有并发限制的数据库路径使用原事件回退;数据库不可用、受压或写入失败时明确返回 `Failed`,保留 first-byte 状态,不误报已入队或已缓冲。这类输入错误不打开 Redis 熔断,也不会无限重试。重试接收前再次校验,覆盖主路径已熔断或入队槽耗尽的旁路;重试 worker 也会终止单条永久失败并继续处理后续条目。 - **指标与临时分配:** 导出队列 payload 上限、诊断降级、编码拒绝及永久重试失败计数。payload 计数是进程级编码尝试,包含入队与重试预校验,不是唯一事件数。超长 tier/reasoning 字符串先检查已有的 64 字节限制,再执行大小写规范化,避免明知非法仍复制整个字符串。 diff --git a/frontend/src/features/usage/utils/__tests__/body-document-engine.spec.ts b/frontend/src/features/usage/utils/__tests__/body-document-engine.spec.ts index ed5e9f413..b04d86c07 100644 --- a/frontend/src/features/usage/utils/__tests__/body-document-engine.spec.ts +++ b/frontend/src/features/usage/utils/__tests__/body-document-engine.spec.ts @@ -1,12 +1,31 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { gzipSync } from 'node:zlib' import { BodyDocumentEngine, decodeBody } from '../body-document-engine' import { JSON_PAGE_SIZE, JSON_TEXT_CHUNK_SIZE } from '../json-viewer' +import type { BodyWorkerRequest } from '../body-document-protocol' function bytes(value: string) { return new TextEncoder().encode(value).buffer } function gzip(value: string) { return Uint8Array.from(gzipSync(value)).buffer } describe('body document decoding', () => { + it('loads and copies complete captured bodies through the worker entry point', async () => { + const postMessage = vi.fn() + vi.stubGlobal('postMessage', postMessage) + vi.stubGlobal('onmessage', undefined) + try { + await import('../body-document.worker') + const dispatch = globalThis.onmessage as unknown as (event: { data: BodyWorkerRequest }) => Promise + const value = { messages: [{ role: 'user', content: `${'x'.repeat(100_000)}BODY-END` }] } + const text = JSON.stringify(value) + await dispatch({ data: { id: 1, action: 'load', bytes: gzip(text), encoding: 'gzip' } }) + expect(postMessage).toHaveBeenLastCalledWith({ id: 1, ok: true, result: { byteLength: bytes(text).byteLength } }) + await dispatch({ data: { id: 2, action: 'copy' } }) + expect(postMessage).toHaveBeenLastCalledWith({ id: 2, ok: true, result: JSON.stringify(value, null, 2) }) + } finally { + vi.unstubAllGlobals() + } + }) + it.each(['gzip', 'json'] as const)('decodes %s off the UI protocol with a byte count', async encoding => { const text = JSON.stringify({ text: '你好🙂', count: 0, enabled: false }) const decoded = await decodeBody(encoding === 'gzip' ? gzip(text) : bytes(text), encoding) From fdf55525f5af939293860c0691c79bbc07bc5af1 Mon Sep 17 00:00:00 2001 From: stabey <36232531+stabey@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:08:12 +0800 Subject: [PATCH 17/72] fix(ai-formats): keep client-declared search tools as Gemini function declarations canonical_tools_to_gemini promoted any tool whose name normalized to "websearch" / "googlesearch" / "websearchpreview" into Gemini's server-side builtin, dropping it from functionDeclarations. Claude Code declares an ordinary client-side `WebSearch` tool with a full input_schema, so every /v1/messages request routed to a Gemini model lost that declaration and gained `googleSearch` (rewritten to `googleSearchRetrieval` at the Anti Gravity transport boundary) instead. Two consequences, both observed on stabey-124 against gemini-3.8-flash: - the model can never emit a `WebSearch` tool_use, so the client's own web search is dead on that route; - when the model does reach for the injected server-side search, the v1internal backend answers `finishReason: MALFORMED_FUNCTION_CALL` / "Function call is empty - no input to parse." and the turn fails. Promote a tool to a builtin only when it is a bare marker carrying no schema. A declared schema means the caller intends to execute the call itself, which matches CLIProxyAPI: it keys builtins off Claude's `type: web_search_*` or an explicit `google_search` tool key and never off a function name. Co-Authored-By: Claude Opus 5 --- .../gemini/generate_content/request.rs | 73 ++++++++++++++++++- .../src/formats/shared/standard_matrix.rs | 60 +++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs b/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs index cfda9c244..7b95dcc1f 100644 --- a/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs +++ b/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs @@ -717,7 +717,7 @@ fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option { let mut url_context = false; for tool in &canonical.tools { - match normalize_gemini_builtin_tool_name(&tool.name) { + match canonical_tool_builtin_gemini_name(tool) { Some("googleSearch") => { google_search = true; continue; @@ -980,6 +980,25 @@ fn compact_gemini_contents(contents: Vec) -> Vec { compact } +/// Promote a canonical tool to a Gemini builtin only when it is a bare marker. +/// +/// Clients declare ordinary function tools whose names collide with the builtin +/// spellings — Claude Code ships a client-side `WebSearch` tool with a full +/// `input_schema`. Matching on the name alone dropped those declarations and +/// replaced them with server-side grounding, so the model could never call the +/// tool the client actually implements. A declared schema means the caller +/// expects to execute the call itself, so such tools stay function declarations. +fn canonical_tool_builtin_gemini_name(tool: &CanonicalToolDefinition) -> Option<&'static str> { + if tool + .parameters + .as_ref() + .is_some_and(|parameters| !parameters.is_null()) + { + return None; + } + normalize_gemini_builtin_tool_name(&tool.name) +} + fn normalize_gemini_builtin_tool_name(name: &str) -> Option<&'static str> { match name .trim() @@ -1323,4 +1342,56 @@ mod tests { assert!(to_raw(&canonical, "gemini-2.5-pro", false).is_none()); assert!(to_raw(&canonical, "gemini-3-flash-preview", false).is_some()); } + + #[test] + fn client_declared_web_search_tool_stays_a_function_declaration() { + let canonical = CanonicalRequest { + model: "gemini-3-flash-preview".to_string(), + tools: vec![CanonicalToolDefinition { + name: "WebSearch".to_string(), + description: Some("Search the web".to_string()), + parameters: Some(json!({ + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + })), + strict: None, + extensions: BTreeMap::new(), + }], + ..CanonicalRequest::default() + }; + + let tools = canonical_tools_to_gemini(&canonical).expect("tools should be emitted"); + let tools = tools.as_array().expect("tools should be an array"); + + assert!( + tools.iter().all(|tool| tool.get("googleSearch").is_none()), + "a client tool named WebSearch must not become server-side grounding: {tools:?}" + ); + assert_eq!( + tools[0]["functionDeclarations"][0]["name"], "WebSearch", + "the client declaration must survive: {tools:?}" + ); + } + + #[test] + fn schemaless_builtin_tool_name_still_maps_to_google_search() { + let canonical = CanonicalRequest { + model: "gemini-3-flash-preview".to_string(), + tools: vec![CanonicalToolDefinition { + name: "google_search".to_string(), + description: None, + parameters: None, + strict: None, + extensions: BTreeMap::new(), + }], + ..CanonicalRequest::default() + }; + + let tools = canonical_tools_to_gemini(&canonical).expect("tools should be emitted"); + let tools = tools.as_array().expect("tools should be an array"); + + assert_eq!(tools.len(), 1, "{tools:?}"); + assert_eq!(tools[0]["googleSearch"], json!({})); + } } diff --git a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs index c85760d3b..3a862f407 100644 --- a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs @@ -1951,6 +1951,66 @@ mod tests { assert_eq!(converted["tools"][0]["googleSearch"], json!({})); } + #[test] + fn claude_client_web_search_tool_survives_conversion_to_gemini() { + let request = json!({ + "model": "gemini-3-flash-preview", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "find the release notes"}], + "tools": [ + { + "name": "WebSearch", + "description": "Search the web and use the results to inform responses", + "input_schema": { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"] + } + }, + { + "name": "Read", + "description": "Read a file", + "input_schema": { + "type": "object", + "properties": {"file_path": {"type": "string"}}, + "required": ["file_path"] + } + } + ] + }); + + let converted = build_standard_request_body( + &request, + "claude:messages", + "gemini-3-flash-preview", + "google", + "gemini:generate_content", + "/v1/messages", + false, + None, + None, + ) + .expect("claude messages should convert to gemini"); + + let tools = converted["tools"] + .as_array() + .expect("tools should be an array"); + assert!( + tools.iter().all(|tool| tool.get("googleSearch").is_none() + && tool.get("googleSearchRetrieval").is_none()), + "a client-declared WebSearch tool must not become server-side grounding: {tools:?}" + ); + + let declared: Vec<&str> = tools + .iter() + .filter_map(|tool| tool.get("functionDeclarations")) + .filter_map(Value::as_array) + .flatten() + .filter_map(|declaration| declaration.get("name").and_then(Value::as_str)) + .collect(); + assert_eq!(declared, vec!["WebSearch", "Read"], "{tools:?}"); + } + #[test] fn builds_claude_request_from_openai_chat_with_thinking_and_data_url_image() { let request = json!({ From 6c92db2ba56a8fb039464798a9440e5accf698b5 Mon Sep 17 00:00:00 2001 From: stabey <36232531+stabey@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:34:13 +0800 Subject: [PATCH 18/72] fix(antigravity): send googleSearch instead of the Gemini 1.5 retrieval tool The transport boundary rewrote `googleSearch` into the Gemini 1.5-era `googleSearchRetrieval` spelling before every v1internal call, on the stated grounds that the private backend rejects `googleSearch` when it is combined with function declarations. That rewrite breaks grounding on Gemini 3. Observed on stabey-124 against daily-cloudcode-pa.googleapis.com. A controlled pair, same model and keys, 5 seconds apart: - no `web_search_options` -> 200 - with `web_search_options` -> 502 on all three candidates The outgoing body carried `tools: [{"googleSearchRetrieval": {}}]` and no function declarations at all, so the documented mixed-tool rationale did not apply. `request_candidates.error_message` holds what the backend actually said: Malformed function call: call:google_search{query:current UTC date time} Malformed function call: call:google:search{query:current UTC date} Malformed function call: call:google_search{queries:[current UTC date]} The model reaches for `google_search`, the legacy declaration binds nothing, and the turn dies unparsed. CLIProxyAPI sends `googleSearch` to this same v1internal surface, including alongside function declarations. Keep folding the snake_case `google_search` alias into the canonical `googleSearch` key, and leave a request that already spells the tool `googleSearchRetrieval` untouched. Co-Authored-By: Claude Opus 5 --- .../transport/src/antigravity/request.rs | 70 ++++++++++++++----- 1 file changed, 53 insertions(+), 17 deletions(-) diff --git a/crates/aether-provider/transport/src/antigravity/request.rs b/crates/aether-provider/transport/src/antigravity/request.rs index c25a1cb97..c6002f295 100644 --- a/crates/aether-provider/transport/src/antigravity/request.rs +++ b/crates/aether-provider/transport/src/antigravity/request.rs @@ -120,11 +120,18 @@ pub fn build_antigravity_safe_v1internal_request( AntigravityRequestEnvelopeSupport::Supported(envelope) } -/// Antigravity's private v1internal Gemini surface still uses the legacy -/// `googleSearchRetrieval` spelling. The public Gemini converter emits the -/// newer `googleSearch` spelling, which the private backend rejects when it is -/// combined with function declarations. Normalize only at this transport -/// boundary so public Gemini requests retain their native shape. +/// Antigravity's private v1internal Gemini surface takes the same +/// `googleSearch` grounding tool as the public one. Only the snake_case alias +/// needs folding into the canonical camelCase key. +/// +/// This used to rewrite `googleSearch` into the Gemini 1.5-era +/// `googleSearchRetrieval` spelling. Gemini 3 rejects that: the model emits a +/// `google_search` call the backend cannot bind to any declared tool, and the +/// turn dies with `MALFORMED_FUNCTION_CALL`, e.g. +/// `Malformed function call: call:google_search{query:current UTC date}` +/// observed against `daily-cloudcode-pa.googleapis.com` with +/// `tools: [{"googleSearchRetrieval": {}}]` and no function declarations. +/// CLIProxyAPI sends `googleSearch` to the same v1internal surface. fn normalize_antigravity_builtin_tool_names(request: &mut Map) { let Some(tools) = request.get_mut("tools").and_then(Value::as_array_mut) else { return; @@ -135,14 +142,9 @@ fn normalize_antigravity_builtin_tool_names(request: &mut Map) { continue; }; - if let Some(payload) = tool_object.remove("googleSearch") { - tool_object - .entry("googleSearchRetrieval".to_string()) - .or_insert(payload); - } if let Some(payload) = tool_object.remove("google_search") { tool_object - .entry("googleSearchRetrieval".to_string()) + .entry("googleSearch".to_string()) .or_insert(payload); } } @@ -222,6 +224,43 @@ mod tests { } } + #[test] + fn search_only_request_keeps_the_modern_google_search_spelling() { + // Reproduces the live failure: a grounding-only request (no function + // declarations) that went out as `googleSearchRetrieval` came back as + // `Malformed function call: call:google_search{query:current UTC date}` + // from daily-cloudcode-pa.googleapis.com. + let request_body = json!({ + "contents": [ + { "role": "user", "parts": [{ "text": "today's UTC date?" }] } + ], + "tools": [{ "googleSearch": {} }] + }); + + let envelope = match build_antigravity_safe_v1internal_request( + &sample_auth(), + "request-ant-search-1", + "gemini-3.8-flash-high", + &request_body, + AntigravityEnvelopeRequestType::Agent, + ) { + AntigravityRequestEnvelopeSupport::Supported(envelope) => envelope, + AntigravityRequestEnvelopeSupport::Unsupported(reason) => { + panic!("search-only envelope should be supported: {reason:?}") + } + }; + + let tools = envelope["request"]["tools"] + .as_array() + .expect("tools should survive"); + assert_eq!(tools.len(), 1, "{tools:?}"); + assert_eq!(tools[0]["googleSearch"], json!({})); + assert!( + tools[0].get("googleSearchRetrieval").is_none(), + "the Gemini 1.5 spelling must not be reintroduced: {tools:?}" + ); + } + #[test] fn real_agent_request_preserves_antigravity_agent_fields() { let request_body = json!({ @@ -328,12 +367,9 @@ mod tests { .get("include_server_side_tool_invocations") .is_none()); assert!(envelope["request"]["tools"][0] - .get("googleSearch") + .get("googleSearchRetrieval") .is_none()); - assert_eq!( - envelope["request"]["tools"][0]["googleSearchRetrieval"], - json!({}) - ); + assert_eq!(envelope["request"]["tools"][0]["googleSearch"], json!({})); assert_eq!( envelope["request"]["tools"][1]["functionDeclarations"][0]["name"], "run_command" @@ -471,7 +507,7 @@ mod tests { .get("google_search") .is_none()); assert_eq!( - envelope["request"]["tools"][0]["googleSearchRetrieval"], + envelope["request"]["tools"][0]["googleSearch"], json!({ "dynamicRetrievalConfig": { "mode": "MODE_UNSPECIFIED" From bcb23080006d95eb799b3052074b521c36690429 Mon Sep 17 00:00:00 2001 From: stabey <36232531+stabey@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:41:36 +0800 Subject: [PATCH 19/72] feat(ai-formats): deliver Gemini grounding to every client as native citations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini runs `googleSearch` inside Google. The search leaves no client-visible tool call, and the evidence arrives only as `candidates[].groundingMetadata`. Every cross-format target dropped it wholesale, so a grounded answer reached OpenAI- and Claude-shaped clients as prose that names its sources with nothing structured behind it: no `annotations`, no `citations`, no `url_citation`. Callers that verify grounding — the common "did this model actually search?" check — saw a 200 with no evidence and had to treat the answer as ungrounded. Adapters now normalise `groundingMetadata` into neutral citations and each target renders its own family's standard shape: `url_citation` annotations for `openai:chat` and `openai:responses`, and `web_search_result_location` citations on the text block for `claude:messages`. Gemini reports segment bounds as UTF-8 byte offsets while both targets count characters, so the bounds are converted rather than copied. Streaming is covered too, since that is what grounded traffic actually uses. A new `CanonicalStreamEvent::Citations` carries the neutral list once the answer text is whole — the offsets index into the finished answer, so it rides just ahead of `Finish` rather than as a delta per chunk — and each client emitter renders it: `delta.annotations` chunks, `response.output_text.annotation.added` events (also kept on the finished message item so clients that only read `response.completed` see them), and `citations_delta` content block deltas. For reference, CLIProxyAPI projects grounding only in its antigravity→Claude translator, and only when the client declared a typed `web_search_*` tool; its OpenAI and plain Gemini translators have no grounding handling at all. The citation shape here matches theirs, but the coverage is deliberately wider: all three targets, streaming and non-streaming, with no dependency on a declared tool. Co-Authored-By: Claude Opus 5 --- .../src/formats/claude/messages/stream.rs | 28 +++ .../gemini/generate_content/response.rs | 209 +++++++++++++++++- .../formats/gemini/generate_content/stream.rs | 48 +++- .../formats/src/formats/openai/chat/stream.rs | 61 ++++- .../aether-ai/formats/src/formats/registry.rs | 102 +++++++++ .../formats/src/formats/shared/citations.rs | 113 ++++++++++ .../formats/src/formats/shared/mod.rs | 1 + .../shared/stream_core/format_matrix.rs | 98 ++++++++ .../src/formats/shared/sync_products.rs | 5 + .../formats/src/protocol/canonical.rs | 49 +++- .../aether-ai/formats/src/protocol/stream.rs | 8 + 11 files changed, 707 insertions(+), 15 deletions(-) create mode 100644 crates/aether-ai/formats/src/formats/shared/citations.rs diff --git a/crates/aether-ai/formats/src/formats/claude/messages/stream.rs b/crates/aether-ai/formats/src/formats/claude/messages/stream.rs index af816734c..a20788a38 100644 --- a/crates/aether-ai/formats/src/formats/claude/messages/stream.rs +++ b/crates/aether-ai/formats/src/formats/claude/messages/stream.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; use serde_json::{json, Map, Value}; +use crate::formats::shared::citations::canonical_citations_to_claude_citations; use crate::formats::shared::response::{ build_generated_tool_call_id, canonicalize_tool_arguments, remove_empty_pages_from_tool_arguments, @@ -773,6 +774,33 @@ impl ClaudeClientEmitter { name, content, } => self.emit_tool_result_block(index, tool_use_id, name, content), + CanonicalStreamEvent::Citations(citations) => { + let citations = canonical_citations_to_claude_citations(&citations); + if citations.is_empty() { + return Ok(Vec::new()); + } + // Citations belong to the answer text. If a tool call or a + // thinking block closed it, open a fresh text block rather than + // hang the evidence off an unrelated one. + let mut out = self.ensure_text_block()?; + let Some(ClaudeOpenBlock::Text { block_index }) = self.open_block else { + return Ok(out); + }; + for citation in citations { + out.extend(encode_json_sse( + Some("content_block_delta"), + &json!({ + "type": "content_block_delta", + "index": block_index, + "delta": { + "type": "citations_delta", + "citation": citation, + } + }), + )?); + } + Ok(out) + } CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()), CanonicalStreamEvent::Finish { finish_reason, diff --git a/crates/aether-ai/formats/src/formats/gemini/generate_content/response.rs b/crates/aether-ai/formats/src/formats/gemini/generate_content/response.rs index 5b4208272..d622feb49 100644 --- a/crates/aether-ai/formats/src/formats/gemini/generate_content/response.rs +++ b/crates/aether-ai/formats/src/formats/gemini/generate_content/response.rs @@ -2,15 +2,176 @@ use serde_json::{json, Map, Value}; use crate::{ formats::context::FormatContext, + formats::shared::citations::{ + canonical_citation, canonical_citations_to_claude_citations, + canonical_citations_to_openai_annotations, + }, protocol::canonical::{ canonical_extension_object_mut, canonical_usage_total_input_tokens, canonical_usage_total_tokens_for_inclusive_input, gemini_extensions, gemini_part_to_canonical_block, gemini_stop_reason_to_canonical, gemini_usage_to_canonical, CanonicalContentBlock, CanonicalResponse, CanonicalResponseOutput, CanonicalRole, - CanonicalStopReason, CanonicalUsage, + CanonicalStopReason, CanonicalUsage, CLAUDE_EXTENSION_NAMESPACE, + OPENAI_RESPONSES_EXTENSION_NAMESPACE, }, }; +/// Project Gemini grounding metadata onto the answer text as structured +/// citations. +/// +/// Native `googleSearch` grounding runs inside Google, so there is no +/// client-visible tool call and the evidence only exists in +/// `candidates[].groundingMetadata`. Cross-format targets used to drop that +/// wholesale, leaving callers with prose that names its sources but nothing a +/// client can render or verify. Every grounded span is therefore emitted twice, +/// each time in the target family's own standard shape: OpenAI `url_citation` +/// annotations and Claude `web_search_result_location` citations. Both ride +/// extension namespaces the respective emitters already merge onto the text +/// block, so no target has to learn anything Gemini-specific. +fn attach_gemini_grounding_citations( + candidate: &Map, + content: &mut [CanonicalContentBlock], +) { + let Some(grounding) = gemini_candidate_grounding(candidate) else { + return; + }; + let Some(block) = content.iter_mut().find(|block| { + matches!(block, CanonicalContentBlock::Text { text, .. } if !text.trim().is_empty()) + }) else { + return; + }; + let CanonicalContentBlock::Text { text, extensions } = block else { + return; + }; + + let citations = gemini_grounding_citations(grounding, text); + if citations.is_empty() { + return; + } + let annotations = canonical_citations_to_openai_annotations(&citations); + let claude_citations = canonical_citations_to_claude_citations(&citations); + canonical_extension_object_mut(extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE) + .entry("annotations".to_string()) + .or_insert_with(|| Value::Array(annotations)); + canonical_extension_object_mut(extensions, CLAUDE_EXTENSION_NAMESPACE) + .entry("citations".to_string()) + .or_insert_with(|| Value::Array(claude_citations)); +} + +pub(crate) fn gemini_candidate_grounding(candidate: &Map) -> Option<&Value> { + candidate + .get("groundingMetadata") + .or_else(|| candidate.get("grounding_metadata")) +} + +/// Normalise `groundingMetadata` into neutral citations against `text`. +/// +/// Gemini reports segment bounds as UTF-8 byte offsets while every target +/// counts characters, so the bounds are converted rather than copied. +pub(crate) fn gemini_grounding_citations(grounding: &Value, text: &str) -> Vec { + let chunks = grounding + .get("groundingChunks") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + if chunks.is_empty() { + return Vec::new(); + } + + let supports = grounding + .get("groundingSupports") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + + let mut citations = Vec::new(); + for support in supports { + let segment = support.get("segment"); + let start = segment + .and_then(|segment| segment.get("startIndex")) + .and_then(Value::as_u64) + .unwrap_or(0); + let end = segment + .and_then(|segment| segment.get("endIndex")) + .and_then(Value::as_u64); + let start_byte = gemini_clamped_byte_offset(text, start); + let end_byte = end + .map(|end| gemini_clamped_byte_offset(text, end)) + .filter(|end| *end >= start_byte); + let cited_text = segment + .and_then(|segment| segment.get("text")) + .and_then(Value::as_str) + .or_else(|| end_byte.map(|end| &text[start_byte..end])) + .map(str::trim) + .filter(|cited_text| !cited_text.is_empty()); + let indices = support + .get("groundingChunkIndices") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + for index in indices { + let Some(chunk) = index + .as_u64() + .and_then(|index| usize::try_from(index).ok()) + .and_then(|index| chunks.get(index)) + else { + continue; + }; + let Some((uri, title)) = gemini_grounding_chunk_source(chunk) else { + continue; + }; + citations.push(canonical_citation( + uri, + title, + Some(text[..start_byte].chars().count()), + end_byte.map(|end| text[..end].chars().count()), + cited_text, + )); + } + } + + // `groundingSupports` is optional; without it the chunks are still the + // evidence, just unanchored. + if citations.is_empty() { + for chunk in chunks { + let Some((uri, title)) = gemini_grounding_chunk_source(chunk) else { + continue; + }; + citations.push(canonical_citation(uri, title, None, None, None)); + } + } + citations +} + +fn gemini_grounding_chunk_source(chunk: &Value) -> Option<(&str, Option<&str>)> { + let source = chunk.get("web").or_else(|| chunk.get("retrievedContext"))?; + let uri = source + .get("uri") + .or_else(|| source.get("url")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|uri| !uri.is_empty())?; + let title = source + .get("title") + .and_then(Value::as_str) + .map(str::trim) + .filter(|title| !title.is_empty()); + Some((uri, title)) +} + +/// Gemini offsets are byte counts into the UTF-8 answer. A truncated or stale +/// offset must not panic the conversion, so snap it into range and back onto a +/// character boundary. +fn gemini_clamped_byte_offset(text: &str, byte_offset: u64) -> usize { + let mut offset = usize::try_from(byte_offset) + .unwrap_or(text.len()) + .min(text.len()); + while offset > 0 && !text.is_char_boundary(offset) { + offset -= 1; + } + offset +} + pub fn from(body: &Value, _ctx: &FormatContext) -> Option { from_raw(body) } @@ -37,11 +198,12 @@ pub fn from_raw(body_json: &Value) -> Option { .and_then(Value::as_array) .map(Vec::as_slice) .unwrap_or(&[]); - let content = parts + let mut content = parts .iter() .enumerate() .filter_map(|(index, part)| gemini_part_to_canonical_block(part, index)) .collect::>(); + attach_gemini_grounding_citations(candidate_object, &mut content); let mut stop_reason = candidate_object .get("finishReason") .or_else(|| candidate_object.get("finish_reason")) @@ -433,6 +595,49 @@ mod tests { use super::*; use crate::CanonicalContentBlock; + /// Gemini omits `groundingSupports` when it cannot anchor the answer to a + /// span. The sources are still real, so they must survive unanchored + /// rather than be dropped for lacking offsets. + #[test] + fn grounding_without_supports_still_yields_unanchored_citations() { + let body = json!({ + "responseId": "resp-unanchored", + "candidates": [{ + "content": {"role": "model", "parts": [{"text": "Rust 1.95 is current."}]}, + "finishReason": "STOP", + "groundingMetadata": { + "groundingChunks": [ + {"web": {"uri": "https://blog.rust-lang.org/", "title": "Rust Blog"}}, + {"web": {"title": "no uri here"}} + ] + } + }] + }); + + let canonical = from_raw(&body).expect("canonical"); + let CanonicalContentBlock::Text { extensions, .. } = &canonical.outputs[0].content[0] + else { + panic!("expected a text block"); + }; + + assert_eq!( + extensions["claude"]["citations"], + json!([{ + "type": "web_search_result_location", + "url": "https://blog.rust-lang.org/", + "title": "Rust Blog", + }]) + ); + assert_eq!( + extensions["openai_responses"]["annotations"], + json!([{ + "type": "url_citation", + "url": "https://blog.rust-lang.org/", + "title": "Rust Blog", + }]) + ); + } + #[test] fn gemini_response_without_visible_parts_is_not_success() { let body = json!({ diff --git a/crates/aether-ai/formats/src/formats/gemini/generate_content/stream.rs b/crates/aether-ai/formats/src/formats/gemini/generate_content/stream.rs index de5b46e6d..8300258fc 100644 --- a/crates/aether-ai/formats/src/formats/gemini/generate_content/stream.rs +++ b/crates/aether-ai/formats/src/formats/gemini/generate_content/stream.rs @@ -2,6 +2,9 @@ use std::collections::BTreeMap; use serde_json::{json, Map, Value}; +use crate::formats::gemini::generate_content::response::{ + gemini_candidate_grounding, gemini_grounding_citations, +}; use crate::formats::shared::response::{build_generated_tool_call_id, canonicalize_tool_arguments}; use crate::formats::shared::sse::encode_json_sse; use crate::formats::shared::stream_core::common::*; @@ -36,6 +39,10 @@ pub struct GeminiProviderState { content_parts: BTreeMap, tool_calls: BTreeMap, tool_results: BTreeMap, + /// Last `groundingMetadata` seen. Gemini resends it cumulatively, so the + /// newest copy is the complete one; citations are emitted once at finish, + /// when the answer text they index into is whole. + grounding: Option, } impl GeminiProviderState { @@ -68,6 +75,31 @@ impl GeminiProviderState { self.started = true; } + /// Turn the grounding metadata collected over the stream into citations. + /// + /// The offsets Gemini reports index into the finished answer, so this can + /// only run once the text is complete — hence a single frame just ahead of + /// `Finish` rather than a delta per chunk. + fn push_citations_frame(&mut self, id: &str, model: &str, out: &mut Vec) { + let Some(grounding) = self.grounding.take() else { + return; + }; + let text = self + .text_parts + .values() + .map(String::as_str) + .collect::(); + let citations = gemini_grounding_citations(&grounding, &text); + if citations.is_empty() { + return; + } + out.push(CanonicalStreamFrame { + id: id.to_string(), + model: model.to_string(), + event: CanonicalStreamEvent::Citations(citations), + }); + } + fn unknown_frame(&self, report_context: &Value, payload: Value) -> CanonicalStreamFrame { let (id, model) = self.identity(report_context); CanonicalStreamFrame { @@ -120,6 +152,11 @@ impl GeminiProviderState { response_model.as_str(), event_object.get("usageMetadata"), ); + if !self.terminal_observation_only { + if let Some(grounding) = gemini_candidate_grounding(candidate_object) { + self.grounding = Some(grounding.clone()); + } + } let Some(content) = candidate_object.get("content").and_then(Value::as_object) else { if let Some(payload) = terminal_error { out.push(self.unknown_frame(report_context, payload)); @@ -361,6 +398,7 @@ impl GeminiProviderState { if has_tool_calls && finish_reason.as_deref().is_none_or(|value| value == "stop") { finish_reason = Some("tool_calls".to_string()); } + self.push_citations_frame(&id, &model, &mut out); out.push(CanonicalStreamFrame { id, model, @@ -385,14 +423,17 @@ impl GeminiProviderState { } self.finished = true; let (id, model) = self.identity(report_context); - Ok(vec![CanonicalStreamFrame { + let mut out = Vec::new(); + self.push_citations_frame(&id, &model, &mut out); + out.push(CanonicalStreamFrame { id, model, event: CanonicalStreamEvent::Finish { finish_reason: None, usage: None, }, - }]) + }); + Ok(out) } } @@ -674,6 +715,9 @@ impl GeminiClientEmitter { None, None, ), + // Only Gemini produces citations today, and a Gemini-to-Gemini + // stream keeps its own `groundingMetadata` on the passthrough path. + CanonicalStreamEvent::Citations(_) => Ok(Vec::new()), CanonicalStreamEvent::UnknownEvent(_) => Ok(Vec::new()), CanonicalStreamEvent::Finish { finish_reason, diff --git a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs index eff7c235f..1261921ed 100644 --- a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs +++ b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs @@ -13,6 +13,7 @@ use crate::formats::openai::responses::{ }, GeminiToolSignatureCarrierDirection, }; +use crate::formats::shared::citations::canonical_citations_to_openai_annotations; use crate::formats::shared::response::build_generated_tool_call_id; use crate::formats::shared::sse::{encode_done_sse, encode_json_sse}; use crate::formats::shared::stream_core::common::*; @@ -2161,6 +2162,9 @@ pub struct OpenAIResponsesClientEmitter { text_item_started: bool, text_part_started: bool, message_output_index: Option, + /// Citations projected onto the answer text, kept on the finished message + /// item so non-incremental clients see them too. + annotations: Vec, text: String, reasoning: String, reasoning_part: String, @@ -2286,6 +2290,28 @@ impl OpenAIChatClientEmitter { }))?); Ok(out) } + CanonicalStreamEvent::Citations(citations) => { + let annotations = canonical_citations_to_openai_annotations(&citations); + if annotations.is_empty() { + return Ok(Vec::new()); + } + let mut out = self.ensure_started()?; + out.extend(self.encode_chunk(json!({ + "id": self.response_id + .as_deref() + .unwrap_or("chatcmpl-local-stream"), + "object": "chat.completion.chunk", + "model": self.model.as_deref().unwrap_or("unknown"), + "choices": [{ + "index": 0, + "delta": { + "annotations": annotations, + }, + "finish_reason": Value::Null + }] + }))?); + Ok(out) + } CanonicalStreamEvent::ReasoningSignature(_) => Ok(Vec::new()), CanonicalStreamEvent::ContentPart(part) => { let placeholder = openai_stream_placeholder_for_content_part(&part); @@ -2894,7 +2920,7 @@ impl OpenAIResponsesClientEmitter { "part": { "type": "output_text", "text": self.text.as_str(), - "annotations": [], + "annotations": self.annotations.as_slice(), } }), )?); @@ -2913,7 +2939,7 @@ impl OpenAIResponsesClientEmitter { "content": [{ "type": "output_text", "text": self.text.as_str(), - "annotations": [], + "annotations": self.annotations.as_slice(), }], } }), @@ -3128,7 +3154,7 @@ impl OpenAIResponsesClientEmitter { "content": [{ "type": "output_text", "text": self.text.as_str(), - "annotations": [], + "annotations": self.annotations.as_slice(), }], }), )); @@ -3351,6 +3377,35 @@ impl OpenAIResponsesClientEmitter { out.extend(self.encode_reasoning_text_delta(&text)?); Ok(out) } + CanonicalStreamEvent::Citations(citations) => { + let annotations = canonical_citations_to_openai_annotations(&citations); + if annotations.is_empty() { + return Ok(Vec::new()); + } + // The text item has to exist before an annotation can point at + // it, and the annotations are also kept on the item itself so + // clients that only read `response.completed` still see them. + let mut out = self.ensure_text_item_started()?; + let item_id = self.message_item_id(); + let output_index = self.message_output_index.unwrap_or(0); + for annotation in annotations { + let annotation_index = self.annotations.len(); + self.annotations.push(annotation.clone()); + out.extend(self.encode_response_event( + "response.output_text.annotation.added", + json!({ + "type": "response.output_text.annotation.added", + "response_id": self.response_id(), + "output_index": output_index, + "item_id": item_id, + "content_index": 0, + "annotation_index": annotation_index, + "annotation": annotation, + }), + )?); + } + Ok(out) + } CanonicalStreamEvent::ReasoningSummaryDone => { // Close the current reasoning part and reset state so the next // ReasoningDelta starts a fresh part within the same item. diff --git a/crates/aether-ai/formats/src/formats/registry.rs b/crates/aether-ai/formats/src/formats/registry.rs index 289a08614..a1d3a4313 100644 --- a/crates/aether-ai/formats/src/formats/registry.rs +++ b/crates/aether-ai/formats/src/formats/registry.rs @@ -3594,6 +3594,108 @@ mod tests { .any(|field| field.field == "messages")); } + /// Gemini runs `googleSearch` server-side, so the only trace of the search + /// is `groundingMetadata`. Clients on the other formats have to receive it + /// as their own native citations or the answer arrives unverifiable. + #[test] + fn gemini_grounding_reaches_every_cross_format_client_as_citations() { + let gemini = grounded_gemini_response(); + + for target in ["openai:chat", "openai:responses"] { + let converted = + convert_response_pure("gemini:generate_content", target, &gemini).expect(target); + let body = serde_json::to_string(&converted.value).expect("serialize"); + let annotations = find_first_array(&converted.value, "annotations") + .unwrap_or_else(|| panic!("{target} dropped the grounding metadata: {body}")); + assert_eq!( + annotations, + &json!([{ + "type": "url_citation", + "url": "https://time.gov/", + "title": "time.gov", + "start_index": 0, + "end_index": 9, + }]), + "{target} annotations" + ); + } + + let converted = + convert_response_pure("gemini:generate_content", "claude:messages", &gemini) + .expect("claude:messages"); + let body = serde_json::to_string(&converted.value).expect("serialize"); + let citations = find_first_array(&converted.value, "citations") + .unwrap_or_else(|| panic!("claude:messages dropped the grounding metadata: {body}")); + assert_eq!( + citations, + &json!([{ + "type": "web_search_result_location", + "url": "https://time.gov/", + "title": "time.gov", + "cited_text": "今天是 2026", + }]) + ); + } + + /// The grounded span is reported in UTF-8 bytes but every target counts + /// characters, so a multi-byte answer must not shift the citation. + #[test] + fn gemini_grounding_offsets_are_converted_from_bytes_to_characters() { + let converted = convert_response_pure( + "gemini:generate_content", + "openai:chat", + &grounded_gemini_response(), + ) + .expect("convert"); + let annotation = + &find_first_array(&converted.value, "annotations").expect("annotations")[0]; + + // "今天是 2026 " is 15 bytes but 9 characters. + assert_eq!(annotation["end_index"], json!(9)); + } + + fn grounded_gemini_response() -> serde_json::Value { + json!({ + "responseId": "resp_grounded", + "modelVersion": "gemini-3.8-flash", + "candidates": [{ + "index": 0, + "finishReason": "STOP", + "groundingMetadata": { + "webSearchQueries": ["current UTC date"], + "groundingChunks": [{ + "web": {"uri": "https://time.gov/", "title": "time.gov"} + }], + "groundingSupports": [{ + "segment": {"startIndex": 0, "endIndex": 15}, + "groundingChunkIndices": [0] + }] + }, + "content": {"parts": [{"text": "今天是 2026 年"}]} + }] + }) + } + + fn find_first_array<'a>( + value: &'a serde_json::Value, + key: &str, + ) -> Option<&'a serde_json::Value> { + match value { + serde_json::Value::Object(object) => { + if let Some(found) = object.get(key).filter(|found| found.is_array()) { + return Some(found); + } + object + .values() + .find_map(|value| find_first_array(value, key)) + } + serde_json::Value::Array(items) => { + items.iter().find_map(|item| find_first_array(item, key)) + } + _ => None, + } + } + #[test] fn runtime_responses_to_gemini_rejects_mixed_tools_for_gemini_two() { let body = json!({ diff --git a/crates/aether-ai/formats/src/formats/shared/citations.rs b/crates/aether-ai/formats/src/formats/shared/citations.rs new file mode 100644 index 000000000..561c268ac --- /dev/null +++ b/crates/aether-ai/formats/src/formats/shared/citations.rs @@ -0,0 +1,113 @@ +//! Provider-neutral source citations. +//! +//! Some providers ground an answer server-side (Gemini's native `googleSearch` +//! is the motivating case): the search leaves no client-visible tool call, and +//! the evidence arrives only as provider-specific metadata alongside the text. +//! Dropping it leaves callers with prose that names its sources but nothing +//! they can render, link, or verify. +//! +//! Adapters therefore normalise that metadata into the neutral citation shape +//! below, and each target renders it into its own family's standard shape. +//! Neither side has to learn the other's vocabulary. + +use serde_json::{Map, Value}; + +/// Build one neutral citation. +/// +/// `start_index` / `end_index` are character offsets into the answer text — +/// providers that report byte offsets convert before calling. Every field but +/// `url` is optional, because providers routinely ground an answer without +/// anchoring it to a span. +pub(crate) fn canonical_citation( + url: &str, + title: Option<&str>, + start_index: Option, + end_index: Option, + cited_text: Option<&str>, +) -> Value { + let mut citation = Map::new(); + citation.insert("url".to_string(), Value::String(url.to_string())); + if let Some(title) = title { + citation.insert("title".to_string(), Value::String(title.to_string())); + } + if let Some(start_index) = start_index { + citation.insert("start_index".to_string(), Value::from(start_index as u64)); + } + if let Some(end_index) = end_index { + citation.insert("end_index".to_string(), Value::from(end_index as u64)); + } + if let Some(cited_text) = cited_text { + citation.insert( + "cited_text".to_string(), + Value::String(cited_text.to_string()), + ); + } + Value::Object(citation) +} + +fn citation_string<'a>(citation: &'a Value, key: &str) -> Option<&'a str> { + citation + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +/// Render a neutral citation as an OpenAI `url_citation` annotation, the shape +/// both `chat.completions` and `responses` attach to assistant text. +pub(crate) fn canonical_citation_to_openai_annotation(citation: &Value) -> Option { + let url = citation_string(citation, "url")?; + let mut annotation = Map::new(); + annotation.insert( + "type".to_string(), + Value::String("url_citation".to_string()), + ); + annotation.insert("url".to_string(), Value::String(url.to_string())); + if let Some(title) = citation_string(citation, "title") { + annotation.insert("title".to_string(), Value::String(title.to_string())); + } + for key in ["start_index", "end_index"] { + if let Some(index) = citation.get(key).and_then(Value::as_u64) { + annotation.insert(key.to_string(), Value::from(index)); + } + } + Some(Value::Object(annotation)) +} + +/// Render a neutral citation as a Claude `web_search_result_location`, the +/// shape Claude puts in a text block's `citations`. +pub(crate) fn canonical_citation_to_claude_citation(citation: &Value) -> Option { + let url = citation_string(citation, "url")?; + let mut out = Map::new(); + out.insert( + "type".to_string(), + Value::String("web_search_result_location".to_string()), + ); + out.insert("url".to_string(), Value::String(url.to_string())); + if let Some(title) = citation_string(citation, "title") { + out.insert("title".to_string(), Value::String(title.to_string())); + } + if let Some(cited_text) = citation_string(citation, "cited_text") { + out.insert( + "cited_text".to_string(), + Value::String(cited_text.to_string()), + ); + } + Some(Value::Object(out)) +} + +/// Render every citation that carries a usable URL. +pub(crate) fn canonical_citations_to_openai_annotations(citations: &[Value]) -> Vec { + citations + .iter() + .filter_map(canonical_citation_to_openai_annotation) + .collect() +} + +/// Render every citation that carries a usable URL. +pub(crate) fn canonical_citations_to_claude_citations(citations: &[Value]) -> Vec { + citations + .iter() + .filter_map(canonical_citation_to_claude_citation) + .collect() +} diff --git a/crates/aether-ai/formats/src/formats/shared/mod.rs b/crates/aether-ai/formats/src/formats/shared/mod.rs index 00fc97d12..0a21b9bdb 100644 --- a/crates/aether-ai/formats/src/formats/shared/mod.rs +++ b/crates/aether-ai/formats/src/formats/shared/mod.rs @@ -6,6 +6,7 @@ use std::fmt; /// a base64 field cannot trigger an unchecked allocation before parsing. pub(crate) const MAX_SYNC_REPORT_BODY_BYTES: usize = 64 * 1024 * 1024; +pub mod citations; pub mod error_body; pub mod family; pub mod image_bridge; diff --git a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs index bdf162e87..680de2b08 100644 --- a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs @@ -875,6 +875,104 @@ mod tests { format!("event: {event}\n").into_bytes() } + /// Gemini runs `googleSearch` inside Google, so a grounded streaming answer + /// carries its evidence as `groundingMetadata` on the final chunk and never + /// as a tool call. Each client family has to receive it in its own citation + /// shape, or the answer streams out unverifiable. + #[test] + fn streams_gemini_grounding_to_every_client_as_native_citations() { + let text = "今天是 2026 年"; + let first = json!({ + "responseId": "resp_grounded", + "modelVersion": "gemini-3.8-flash", + "candidates": [{ + "index": 0, + "content": {"role": "model", "parts": [{"text": text}]} + }] + }); + let last = json!({ + "responseId": "resp_grounded", + "modelVersion": "gemini-3.8-flash", + "candidates": [{ + "index": 0, + "finishReason": "STOP", + "content": {"role": "model", "parts": [{"text": text}]}, + "groundingMetadata": { + "webSearchQueries": ["current UTC date"], + "groundingChunks": [{ + "web": {"uri": "https://time.gov/", "title": "time.gov"} + }], + "groundingSupports": [{ + "segment": {"startIndex": 0, "endIndex": 15}, + "groundingChunkIndices": [0] + }] + } + }] + }); + + for (client_api_format, marker) in [ + ("openai:chat", "\"annotations\":[{\"type\":\"url_citation\""), + ( + "openai:responses", + "event: response.output_text.annotation.added\n", + ), + ("claude:messages", "\"type\":\"citations_delta\""), + ] { + let context = report_context("gemini:generate_content", client_api_format); + let mut matrix = StreamingStandardFormatMatrix::default(); + let mut output = matrix + .transform_line(&context, data_line(first.clone())) + .expect("text chunk"); + output.extend( + matrix + .transform_line(&context, data_line(last.clone())) + .expect("grounded chunk"), + ); + output.extend(matrix.finish(&context).expect("finish")); + let sse = String::from_utf8(output).expect("valid SSE"); + + assert!( + sse.contains(marker), + "{client_api_format} missing citations: {sse}" + ); + assert!( + sse.contains("https://time.gov/"), + "{client_api_format} missing source url: {sse}" + ); + } + } + + /// The citation frame is emitted once the answer is whole, so a provider + /// that closes the stream without a `finishReason` must still deliver it. + #[test] + fn streams_gemini_grounding_even_when_the_provider_never_sends_a_finish_reason() { + let context = report_context("gemini:generate_content", "openai:chat"); + let mut matrix = StreamingStandardFormatMatrix::default(); + let mut output = matrix + .transform_line( + &context, + data_line(json!({ + "responseId": "resp_grounded", + "modelVersion": "gemini-3.8-flash", + "candidates": [{ + "index": 0, + "content": {"role": "model", "parts": [{"text": "grounded"}]}, + "groundingMetadata": { + "groundingChunks": [{"web": {"uri": "https://time.gov/"}}] + } + }] + })), + ) + .expect("grounded chunk"); + output.extend(matrix.finish(&context).expect("finish")); + let sse = String::from_utf8(output).expect("valid SSE"); + + assert!( + sse.contains("url_citation") && sse.contains("https://time.gov/"), + "{sse}" + ); + } + #[test] fn terminal_observer_marks_malformed_gemini_function_call_as_failure() { let context = report_context("gemini:generate_content", "openai:responses"); diff --git a/crates/aether-ai/formats/src/formats/shared/sync_products.rs b/crates/aether-ai/formats/src/formats/shared/sync_products.rs index d20bf68d5..8ebfeb2b3 100644 --- a/crates/aether-ai/formats/src/formats/shared/sync_products.rs +++ b/crates/aether-ai/formats/src/formats/shared/sync_products.rs @@ -3662,6 +3662,11 @@ fn try_aggregate_gemini_stream_sync_response( CanonicalStreamEvent::TextDelta(text) => { append_gemini_text_part(&mut parts, text, false); } + // This rebuilds a raw Gemini body, and every non-`content` + // candidate key — `groundingMetadata` included — is already + // copied across above. Projecting it into citations is the + // job of whoever converts that body onward. + CanonicalStreamEvent::Citations(_) => {} CanonicalStreamEvent::ReasoningDelta(text) => { append_gemini_text_part(&mut parts, text, true); } diff --git a/crates/aether-ai/formats/src/protocol/canonical.rs b/crates/aether-ai/formats/src/protocol/canonical.rs index c3ad55edd..02de35d91 100644 --- a/crates/aether-ai/formats/src/protocol/canonical.rs +++ b/crates/aether-ai/formats/src/protocol/canonical.rs @@ -16,6 +16,7 @@ pub use crate::protocol::stream::{CanonicalStreamEvent, CanonicalStreamFrame}; pub(crate) const OPENAI_RESPONSES_EXTENSION_NAMESPACE: &str = "openai_responses"; pub(crate) const OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE: &str = "openai_cli"; +pub(crate) const CLAUDE_EXTENSION_NAMESPACE: &str = "claude"; const AETHER_EXTENSION_NAMESPACE: &str = "aether"; const CLAUDE_MESSAGES_REQUEST_SOURCE_MARKER: &str = "claude_messages_request"; const CLAUDE_SYSTEM_SOURCE_MARKER: &str = "claude_system"; @@ -5879,7 +5880,11 @@ pub(crate) fn canonical_block_to_claude( let mut out = Map::new(); out.insert("type".to_string(), Value::String("text".to_string())); out.insert("text".to_string(), Value::String(text.clone())); - out.extend(namespace_extension_object(extensions, "claude", &out)); + out.extend(namespace_extension_object( + extensions, + CLAUDE_EXTENSION_NAMESPACE, + &out, + )); Some(Some(Value::Object(out))) } CanonicalContentBlock::Thinking { @@ -5899,7 +5904,11 @@ pub(crate) fn canonical_block_to_claude( Value::String("redacted_thinking".to_string()), ); out.insert("data".to_string(), Value::String(data.clone())); - out.extend(namespace_extension_object(extensions, "claude", &out)); + out.extend(namespace_extension_object( + extensions, + CLAUDE_EXTENSION_NAMESPACE, + &out, + )); return Some(Some(Value::Object(out))); } if !matches!(role, CanonicalRole::Assistant) { @@ -5920,7 +5929,11 @@ pub(crate) fn canonical_block_to_claude( if let Some(signature) = signature.as_ref().filter(|value| !value.is_empty()) { out.insert("signature".to_string(), Value::String(signature.clone())); } - out.extend(namespace_extension_object(extensions, "claude", &out)); + out.extend(namespace_extension_object( + extensions, + CLAUDE_EXTENSION_NAMESPACE, + &out, + )); Some(Some(Value::Object(out))) } CanonicalContentBlock::Image { @@ -5945,7 +5958,11 @@ pub(crate) fn canonical_block_to_claude( "source".to_string(), claude_source_value(media_type.as_deref(), data.as_deref(), url.as_deref())?, ); - out.extend(namespace_extension_object(extensions, "claude", &out)); + out.extend(namespace_extension_object( + extensions, + CLAUDE_EXTENSION_NAMESPACE, + &out, + )); Some(Some(Value::Object(out))) } CanonicalContentBlock::File { @@ -5968,7 +5985,11 @@ pub(crate) fn canonical_block_to_claude( "source".to_string(), claude_source_value(media_type.as_deref(), data.as_deref(), file_url.as_deref())?, ); - out.extend(namespace_extension_object(extensions, "claude", &out)); + out.extend(namespace_extension_object( + extensions, + CLAUDE_EXTENSION_NAMESPACE, + &out, + )); Some(Some(Value::Object(out))) } CanonicalContentBlock::Audio { @@ -5988,7 +6009,11 @@ pub(crate) fn canonical_block_to_claude( None, )?, ); - out.extend(namespace_extension_object(extensions, "claude", &out)); + out.extend(namespace_extension_object( + extensions, + CLAUDE_EXTENSION_NAMESPACE, + &out, + )); Some(Some(Value::Object(out))) } CanonicalContentBlock::ToolUse { @@ -6006,7 +6031,11 @@ pub(crate) fn canonical_block_to_claude( ); out.insert("name".to_string(), Value::String(name.clone())); out.insert("input".to_string(), input); - out.extend(namespace_extension_object(extensions, "claude", &out)); + out.extend(namespace_extension_object( + extensions, + CLAUDE_EXTENSION_NAMESPACE, + &out, + )); Some(Some(Value::Object(out))) } CanonicalContentBlock::ToolResult { @@ -6035,7 +6064,11 @@ pub(crate) fn canonical_block_to_claude( if *is_error { out.insert("is_error".to_string(), Value::Bool(true)); } - out.extend(namespace_extension_object(extensions, "claude", &out)); + out.extend(namespace_extension_object( + extensions, + CLAUDE_EXTENSION_NAMESPACE, + &out, + )); Some(Some(Value::Object(out))) } CanonicalContentBlock::Unknown { diff --git a/crates/aether-ai/formats/src/protocol/stream.rs b/crates/aether-ai/formats/src/protocol/stream.rs index 9c6c5823d..3c2762cea 100644 --- a/crates/aether-ai/formats/src/protocol/stream.rs +++ b/crates/aether-ai/formats/src/protocol/stream.rs @@ -74,6 +74,14 @@ pub enum CanonicalStreamEvent { name: Option, content: String, }, + /// Provider-neutral source citations for the answer text streamed so far. + /// + /// Emitted once, just before `Finish`, by providers that ground an answer + /// server-side and report the evidence as metadata instead of a tool call. + /// Each entry carries `url` plus optional `title`, `cited_text` and + /// `start_index`/`end_index` character offsets; every target renders them + /// into its own family's citation shape. + Citations(Vec), UnknownEvent(Value), Finish { finish_reason: Option, From 5a55116b62e7157447ab9afee35e927429a16e18 Mon Sep 17 00:00:00 2001 From: wangpengxiang Date: Thu, 17 Sep 2026 10:46:09 +0800 Subject: [PATCH 20/72] fix(antigravity): harden tool schemas and Claude thought replay Preserve tool schemas across provider-scoped format conversion and OpenAI gateway planners until the Antigravity boundary. Bound reference expansion, safely merge schema constraints, and lower unsupported Claude unions. Drop unsigned historical Claude thinking without changing Gemini behavior. Add cross-format regression fixtures and retain the project's original error output policy. Validation: 945 format tests and 527 transport tests passed; gateway cargo check passed. --- .../planner/standard/normalize/chat.rs | 60 +- .../planner/standard/normalize/responses.rs | 63 +- .../aether-ai/formats/src/formats/context.rs | 3 + .../gemini/generate_content/request.rs | 149 ++-- .../src/formats/shared/standard_matrix.rs | 29 +- .../src/formats/shared/standard_normalize.rs | 2 +- .../src/antigravity/fabric_exec_schema.json | 24 + .../transport/src/antigravity/mod.rs | 1 + .../transport/src/antigravity/request.rs | 690 ++++++++++++++- .../transport/src/antigravity/schema.rs | 789 ++++++++++++++++++ 10 files changed, 1730 insertions(+), 80 deletions(-) create mode 100644 crates/aether-provider/transport/src/antigravity/fabric_exec_schema.json create mode 100644 crates/aether-provider/transport/src/antigravity/schema.rs diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/chat.rs b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/chat.rs index c113e88f6..e0d44d283 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/chat.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/chat.rs @@ -70,13 +70,23 @@ pub(crate) fn build_cross_format_openai_chat_request_body( request_headers: &http::HeaderMap, enable_model_directives: bool, ) -> Option { - let provider_request_body = surface_build_cross_format_openai_chat_request_body( - body_json, - mapped_model, - provider_api_format, - upstream_is_stream, - enable_model_directives, - )?; + let provider_request_body = if provider_type.trim().eq_ignore_ascii_case("antigravity") + && aether_ai_formats::normalize_api_format_alias(provider_api_format) + == "gemini:generate_content" + { + aether_ai_formats::formats::shared::standard_matrix::build_standard_request_body_with_model_directives( + body_json, "openai:chat", mapped_model, provider_type, provider_api_format, + "", upstream_is_stream, None, user_api_key_id, enable_model_directives, + )? + } else { + surface_build_cross_format_openai_chat_request_body( + body_json, + mapped_model, + provider_api_format, + upstream_is_stream, + enable_model_directives, + )? + }; let mut provider_request_body = apply_standard_provider_request_body_rules_with_request_headers( provider_request_body, @@ -110,6 +120,42 @@ pub(crate) fn build_cross_format_openai_chat_request_body( Some(provider_request_body) } +#[cfg(test)] +mod antigravity_schema_tests { + use super::*; + use serde_json::json; + + #[test] + fn antigravity_chat_route_preserves_tool_schema_and_alternate_responses_shape() { + let schema = json!({"type": "object", "properties": {"mode": {"const": "fast"}}}); + let body = json!({"model": "client", "messages": [{"role": "user", "content": "hi"}], + "tools": [{"type": "function", "function": {"name": "probe", "parameters": schema}}]}); + let responses_body = json!({"model": "client", "input": "hi", + "tools": [{"type": "function", "name": "probe", "parameters": schema}]}); + for input in [body, responses_body] { + for provider in ["antigravity", "gemini"] { + let output = build_cross_format_openai_chat_request_body( + &input, + "claude-test", + provider, + "gemini:generate_content", + true, + false, + None, + None, + &http::HeaderMap::new(), + false, + ) + .unwrap(); + let parameters = &output["tools"][0]["functionDeclarations"][0]["parameters"]; + assert_eq!(parameters == &schema, provider == "antigravity"); + assert!(output.get("stream").is_none()); + assert_eq!(output["contents"][0]["parts"][0]["text"], "hi"); + } + } + } +} + pub(crate) fn build_cross_format_openai_chat_upstream_url( parts: &http::request::Parts, transport: &GatewayProviderTransportSnapshot, diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs index 47c91f1b9..724ec4a94 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs @@ -214,15 +214,25 @@ pub(crate) fn build_cross_format_openai_responses_request_body_with_codex_model_ model_capabilities: Option<&crate::ai_serving::CodexResponsesModelCapabilities>, enable_model_directives: bool, ) -> Option { - let provider_request_body = surface_build_cross_format_openai_responses_request_body( - body_json, - mapped_model, - client_api_format, - provider_api_format, - upstream_is_stream, - enable_model_directives, - history_scope, - )?; + let provider_request_body = if provider_type.trim().eq_ignore_ascii_case("antigravity") + && aether_ai_formats::normalize_api_format_alias(provider_api_format) + == "gemini:generate_content" + { + aether_ai_formats::formats::shared::standard_matrix::build_standard_request_body_with_model_directives( + body_json, client_api_format, mapped_model, provider_type, provider_api_format, + "", upstream_is_stream, None, history_scope, enable_model_directives, + )? + } else { + surface_build_cross_format_openai_responses_request_body( + body_json, + mapped_model, + client_api_format, + provider_api_format, + upstream_is_stream, + enable_model_directives, + history_scope, + )? + }; let mut provider_request_body = apply_standard_provider_request_body_rules_with_request_headers( provider_request_body, @@ -274,6 +284,41 @@ pub(crate) fn build_local_openai_responses_upstream_url( ) } +#[cfg(test)] +mod antigravity_schema_tests { + use super::*; + use serde_json::json; + + #[test] + fn antigravity_responses_route_preserves_tool_schema_without_changing_public_gemini() { + let schema = json!({"type": "object", "properties": {"mode": {"const": "fast"}}}); + let input = json!({"model": "client", "input": "hi", + "tools": [{"type": "function", "name": "probe", "parameters": schema}]}); + for provider in ["antigravity", "gemini"] { + let output = + build_cross_format_openai_responses_request_body_with_codex_model_capabilities( + &input, + "claude-test", + "openai:responses", + "gemini:generate_content", + true, + false, + provider, + None, + &http::HeaderMap::new(), + Some("antigravity-schema-test"), + None, + false, + ) + .unwrap(); + let parameters = &output["tools"][0]["functionDeclarations"][0]["parameters"]; + assert_eq!(parameters == &schema, provider == "antigravity"); + assert!(output.get("stream").is_none()); + assert_eq!(output["contents"][0]["parts"][0]["text"], "hi"); + } + } +} + pub(crate) fn build_cross_format_openai_responses_upstream_url( parts: &http::request::Parts, transport: &GatewayProviderTransportSnapshot, diff --git a/crates/aether-ai/formats/src/formats/context.rs b/crates/aether-ai/formats/src/formats/context.rs index 02b189156..be7dee222 100644 --- a/crates/aether-ai/formats/src/formats/context.rs +++ b/crates/aether-ai/formats/src/formats/context.rs @@ -10,6 +10,8 @@ pub struct FormatContext { pub upstream_is_stream: bool, pub report_context: Option, pub history_scope: Option, + /// Defer tool schema lowering to the private provider transport boundary. + pub preserve_gemini_tool_schemas: bool, } impl FormatContext { @@ -45,6 +47,7 @@ impl FormatContext { upstream_is_stream: false, report_context: self.report_context.clone(), history_scope: self.history_scope.clone(), + preserve_gemini_tool_schemas: false, } } diff --git a/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs b/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs index 7b95dcc1f..c6d51c4e6 100644 --- a/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs +++ b/crates/aether-ai/formats/src/formats/gemini/generate_content/request.rs @@ -31,10 +31,11 @@ pub fn from(body: &Value, ctx: &FormatContext) -> Option { } pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option { - to_raw( + to_raw_with_schema_policy( request, ctx.mapped_model_or(request.model.as_str()), ctx.upstream_is_stream, + ctx.preserve_gemini_tool_schemas, ) } @@ -187,7 +188,21 @@ pub fn to_raw( mapped_model: &str, upstream_is_stream: bool, ) -> Option { - let mut output = canonical_to_gemini_request_body(canonical, mapped_model, upstream_is_stream)?; + to_raw_with_schema_policy(canonical, mapped_model, upstream_is_stream, false) +} + +fn to_raw_with_schema_policy( + canonical: &CanonicalRequest, + mapped_model: &str, + upstream_is_stream: bool, + preserve_tool_schemas: bool, +) -> Option { + let mut output = canonical_to_gemini_request_body( + canonical, + mapped_model, + upstream_is_stream, + preserve_tool_schemas, + )?; apply_gemini_request_extensions(&mut output, &canonical.extensions)?; if !canonical_has_raw_gemini_tools(canonical) { enable_server_side_tool_invocations_for_mixed_tools(&mut output, mapped_model)?; @@ -244,7 +259,8 @@ pub fn ensure_server_side_tool_invocations_for_mixed_tools(output: &mut Value) - } pub(crate) fn canonical_has_mixed_gemini_tools(canonical: &CanonicalRequest) -> bool { - canonical_tools_to_gemini(canonical) + // Only tool kinds matter here; do not lower/expand schemas just to count them. + canonical_tools_to_gemini(canonical, true) .and_then(|tools| tools.as_array().cloned()) .is_some_and(|tools| gemini_tools_are_mixed(&tools)) } @@ -275,6 +291,7 @@ fn canonical_to_gemini_request_body( canonical: &CanonicalRequest, mapped_model: &str, _upstream_is_stream: bool, + preserve_tool_schemas: bool, ) -> Option { let mut output = Map::new(); if !mapped_model.trim().is_empty() { @@ -297,7 +314,7 @@ fn canonical_to_gemini_request_body( { output.insert("generationConfig".to_string(), generation_config); } - if let Some(tools) = canonical_tools_to_gemini(canonical) { + if let Some(tools) = canonical_tools_to_gemini(canonical, preserve_tool_schemas) { output.insert("tools".to_string(), tools); } if let Some(tool_config) = canonical_tool_choice_to_gemini(canonical.tool_choice.as_ref()) { @@ -701,7 +718,10 @@ fn apply_response_format_to_gemini_generation_config( } } -fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option { +fn canonical_tools_to_gemini( + canonical: &CanonicalRequest, + preserve_tool_schemas: bool, +) -> Option { let mut declarations = Vec::new(); let mut tools = Vec::new(); let mut google_search = canonical @@ -747,7 +767,10 @@ fn canonical_tools_to_gemini(canonical: &CanonicalRequest) -> Option { google_search = true; continue; } - declarations.push(canonical_tool_to_gemini_declaration(tool)); + declarations.push(canonical_tool_to_gemini_declaration( + tool, + preserve_tool_schemas, + )); } let mut emitted_google_search = false; let mut emitted_code_execution = false; @@ -877,7 +900,10 @@ fn gemini_unhandled_builtin_tool_portion(tool_object: &Map) -> Op (!builtin.is_empty()).then_some(Value::Object(builtin)) } -fn canonical_tool_to_gemini_declaration(tool: &CanonicalToolDefinition) -> Value { +fn canonical_tool_to_gemini_declaration( + tool: &CanonicalToolDefinition, + preserve_tool_schema: bool, +) -> Value { let mut declaration = Map::new(); declaration.insert("name".to_string(), Value::String(tool.name.clone())); if let Some(description) = &tool.description { @@ -898,7 +924,7 @@ fn canonical_tool_to_gemini_declaration(tool: &CanonicalToolDefinition) -> Value .clone() .or_else(|| tool.parameters.clone()) .map(|mut schema| { - if raw_parameters.is_none() { + if raw_parameters.is_none() && !preserve_tool_schema { clean_gemini_schema(&mut schema); } schema @@ -1217,43 +1243,46 @@ mod tests { #[test] fn canonical_tool_declaration_sanitizes_json_schema_for_gemini() { - let declaration = canonical_tool_to_gemini_declaration(&CanonicalToolDefinition { - name: "inspect".to_string(), - description: None, - parameters: Some(json!({ - "$defs": { - "Target": { - "type": "object", - "properties": { - "secret": { - "type": "string", - "encrypted": true - } + let declaration = canonical_tool_to_gemini_declaration( + &CanonicalToolDefinition { + name: "inspect".to_string(), + description: None, + parameters: Some(json!({ + "$defs": { + "Target": { + "type": "object", + "properties": { + "secret": { + "type": "string", + "encrypted": true + } + }, + "required": ["secret"], + "additionalProperties": false + } + }, + "type": "object", + "properties": { + "target": { + "oneOf": [ + {"$ref": "#/$defs/Target"}, + {"type": "null"} + ] }, - "required": ["secret"], - "additionalProperties": false + "mode": { + "type": ["string", "null"], + "enum": [1, "fast"] + }, + "value": { + "type": ["string", "integer"] + } } - }, - "type": "object", - "properties": { - "target": { - "oneOf": [ - {"$ref": "#/$defs/Target"}, - {"type": "null"} - ] - }, - "mode": { - "type": ["string", "null"], - "enum": [1, "fast"] - }, - "value": { - "type": ["string", "integer"] - } - } - })), - strict: None, - extensions: BTreeMap::new(), - }); + })), + strict: None, + extensions: BTreeMap::new(), + }, + false, + ); assert_eq!( declaration["parameters"], @@ -1361,17 +1390,20 @@ mod tests { ..CanonicalRequest::default() }; - let tools = canonical_tools_to_gemini(&canonical).expect("tools should be emitted"); - let tools = tools.as_array().expect("tools should be an array"); + for preserve_tool_schemas in [false, true] { + let tools = canonical_tools_to_gemini(&canonical, preserve_tool_schemas) + .expect("tools should be emitted"); + let tools = tools.as_array().expect("tools should be an array"); - assert!( - tools.iter().all(|tool| tool.get("googleSearch").is_none()), - "a client tool named WebSearch must not become server-side grounding: {tools:?}" - ); - assert_eq!( - tools[0]["functionDeclarations"][0]["name"], "WebSearch", - "the client declaration must survive: {tools:?}" - ); + assert!( + tools.iter().all(|tool| tool.get("googleSearch").is_none()), + "a client tool named WebSearch must not become server-side grounding: {tools:?}" + ); + assert_eq!( + tools[0]["functionDeclarations"][0]["name"], "WebSearch", + "the client declaration must survive: {tools:?}" + ); + } } #[test] @@ -1388,10 +1420,13 @@ mod tests { ..CanonicalRequest::default() }; - let tools = canonical_tools_to_gemini(&canonical).expect("tools should be emitted"); - let tools = tools.as_array().expect("tools should be an array"); + for preserve_tool_schemas in [false, true] { + let tools = canonical_tools_to_gemini(&canonical, preserve_tool_schemas) + .expect("tools should be emitted"); + let tools = tools.as_array().expect("tools should be an array"); - assert_eq!(tools.len(), 1, "{tools:?}"); - assert_eq!(tools[0]["googleSearch"], json!({})); + assert_eq!(tools.len(), 1, "{tools:?}"); + assert_eq!(tools[0]["googleSearch"], json!({})); + } } } diff --git a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs index 3a862f407..29d5fbc93 100644 --- a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs @@ -131,6 +131,10 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers_and .with_mapped_model(mapped_model) .with_request_path(request_path) .with_upstream_stream(upstream_is_stream); + format_context.preserve_gemini_tool_schemas = + provider_type.trim().eq_ignore_ascii_case("antigravity") + && aether_ai_formats::normalize_api_format_alias(provider_api_format) + == "gemini:generate_content"; if let Some(history_scope) = user_api_key_id { format_context = format_context.with_history_scope(history_scope); } @@ -139,6 +143,23 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers_and client_api_format, provider_api_format, ); + // Keep the specialized OpenAI builders' compatibility/history preprocessing + // when routing them through the provider-aware schema-preserving path. + let antigravity_chat_body = if format_context.preserve_gemini_tool_schemas + && matches!( + aether_ai_formats::normalize_api_format_alias(source_api_format.as_ref()).as_str(), + "openai:chat" | "openai:responses" | "openai:responses:compact" + ) { + Some( + crate::formats::shared::standard_normalize::chat_compatible_body_for_standard_source( + body_json, + source_api_format.as_ref(), + user_api_key_id, + )?, + ) + } else { + None + }; // DeepSeek and xAI replay opaque provider state. Preserve their native // Responses input items: canonical conversion can lose reasoning IDs and // encrypted-only items even when source and destination formats are equal. @@ -152,9 +173,13 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers_and Value::Object(object) } else { convert_request( - source_api_format.as_ref(), + if antigravity_chat_body.is_some() { + "openai:chat" + } else { + source_api_format.as_ref() + }, provider_api_format, - body_json, + antigravity_chat_body.as_deref().unwrap_or(body_json), &format_context, ) .ok()? diff --git a/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs b/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs index de6d1f070..c0e0062a6 100644 --- a/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs +++ b/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs @@ -65,7 +65,7 @@ fn chat_compatible_body_for_openai_chat_endpoint(body_json: &Value) -> Option( +pub(crate) fn chat_compatible_body_for_standard_source<'a>( body_json: &'a Value, client_api_format: &str, history_scope: Option<&str>, diff --git a/crates/aether-provider/transport/src/antigravity/fabric_exec_schema.json b/crates/aether-provider/transport/src/antigravity/fabric_exec_schema.json new file mode 100644 index 000000000..1b98d673e --- /dev/null +++ b/crates/aether-provider/transport/src/antigravity/fabric_exec_schema.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "required": ["code"], + "properties": { + "code": {"type": "string", "description": "TypeScript function body."}, + "payloads": {"type": "object", "patternProperties": {"^.*$": {"type": "string"}}}, + "resultFormat": {"anyOf": [ + {"type": "string", "const": "auto"}, + {"type": "string", "const": "yaml"}, + {"type": "string", "const": "json"}, + {"type": "string", "const": "text"} + ]}, + "tokenBudget": {"type": "number", "minimum": 1}, + "agentBudget": {"type": "number", "minimum": 1}, + "timeoutMs": {"type": "number", "minimum": 1}, + "display": {"anyOf": [ + {"type": "object", "properties": { + "name": {"type": "string"}, + "description": {"type": "string"} + }}, + {"type": "string"} + ]} + } +} diff --git a/crates/aether-provider/transport/src/antigravity/mod.rs b/crates/aether-provider/transport/src/antigravity/mod.rs index 4d09239e2..ac1c91ffb 100644 --- a/crates/aether-provider/transport/src/antigravity/mod.rs +++ b/crates/aether-provider/transport/src/antigravity/mod.rs @@ -1,6 +1,7 @@ mod auth; mod policy; mod request; +mod schema; mod url; pub use auth::{ diff --git a/crates/aether-provider/transport/src/antigravity/request.rs b/crates/aether-provider/transport/src/antigravity/request.rs index c6002f295..2f009b7f3 100644 --- a/crates/aether-provider/transport/src/antigravity/request.rs +++ b/crates/aether-provider/transport/src/antigravity/request.rs @@ -1,6 +1,7 @@ use serde_json::{Map, Value}; use super::auth::{AntigravityRequestAuth, ANTIGRAVITY_REQUEST_USER_AGENT}; +use super::schema::{normalize_claude_unions, normalize_tool_parameters, SchemaBudget}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AntigravityEnvelopeRequestType { @@ -31,6 +32,7 @@ pub enum AntigravityRequestEnvelopeUnsupportedReason { MissingContents, MissingRequestId, MissingModel, + ToolSchemaBudgetExceeded, } pub fn classify_antigravity_safe_request_body( @@ -78,8 +80,14 @@ pub fn build_antigravity_safe_v1internal_request( inner_request.remove("model"); inner_request.remove("safetySettings"); inner_request.remove("safety_settings"); + normalize_antigravity_claude_thought_history(&mut inner_request, model); normalize_antigravity_builtin_tool_names(&mut inner_request); - normalize_antigravity_function_declaration_parameters(&mut inner_request); + if normalize_antigravity_function_declaration_parameters(&mut inner_request, model).is_err() + { + return AntigravityRequestEnvelopeSupport::Unsupported( + AntigravityRequestEnvelopeUnsupportedReason::ToolSchemaBudgetExceeded, + ); + } let request_id = non_empty_string_field(source, "requestId").unwrap_or(request_id); let user_agent = non_empty_string_field(source, "userAgent").unwrap_or(ANTIGRAVITY_REQUEST_USER_AGENT); @@ -104,8 +112,13 @@ pub fn build_antigravity_safe_v1internal_request( inner_request.remove("model"); inner_request.remove("safetySettings"); inner_request.remove("safety_settings"); + normalize_antigravity_claude_thought_history(&mut inner_request, model); normalize_antigravity_builtin_tool_names(&mut inner_request); - normalize_antigravity_function_declaration_parameters(&mut inner_request); + if normalize_antigravity_function_declaration_parameters(&mut inner_request, model).is_err() { + return AntigravityRequestEnvelopeSupport::Unsupported( + AntigravityRequestEnvelopeUnsupportedReason::ToolSchemaBudgetExceeded, + ); + } let mut envelope = serde_json::json!({ "project": auth.project_id, @@ -150,10 +163,47 @@ fn normalize_antigravity_builtin_tool_names(request: &mut Map) { } } -fn normalize_antigravity_function_declaration_parameters(request: &mut Map) { - let Some(tools) = request.get_mut("tools").and_then(Value::as_array_mut) else { +/// Claude requires a replayable signature on historical thinking blocks. In +/// particular, Responses reasoning summaries are not signed thinking. Omit that +/// non-replayable metadata instead of inventing a signature or promoting private +/// reasoning into ordinary assistant text. Keep Gemini's native policy unchanged. +fn normalize_antigravity_claude_thought_history(request: &mut Map, model: &str) { + if !model.trim().to_ascii_lowercase().starts_with("claude-") { + return; + } + let Some(contents) = request.get_mut("contents").and_then(Value::as_array_mut) else { return; }; + contents.retain_mut(|message| { + if message.get("role").and_then(Value::as_str) != Some("model") { + return true; + } + let Some(parts) = message.get_mut("parts").and_then(Value::as_array_mut) else { + return true; + }; + let previous_len = parts.len(); + parts.retain(|part| { + part.get("thought").and_then(Value::as_bool) != Some(true) + || ["thoughtSignature", "thought_signature"].iter().any(|key| { + part.get(*key) + .and_then(Value::as_str) + .is_some_and(|signature| !signature.trim().is_empty()) + }) + }); + // Do not introduce empty messages when a turn contained only a summary. + !parts.is_empty() || parts.len() == previous_len + }); +} + +fn normalize_antigravity_function_declaration_parameters( + request: &mut Map, + model: &str, +) -> Result<(), ()> { + let Some(tools) = request.get_mut("tools").and_then(Value::as_array_mut) else { + return Ok(()); + }; + let mut budget = SchemaBudget::default(); + let claude = model.trim().to_ascii_lowercase().starts_with("claude-"); for tool in tools { let Some(tool_object) = tool.as_object_mut() else { @@ -177,9 +227,16 @@ fn normalize_antigravity_function_declaration_parameters(request: &mut Map) -> Option<&Map> { @@ -216,6 +273,282 @@ mod tests { }; use crate::antigravity::ANTIGRAVITY_REQUEST_USER_AGENT; + #[test] + fn antigravity_claude_fabric_union_regression_across_client_formats() { + use aether_ai_formats::formats::shared::standard_matrix::build_standard_request_body; + let schema: serde_json::Value = + serde_json::from_str(include_str!("fabric_exec_schema.json")).unwrap(); + let clients = [ + ( + "gemini:generate_content", + json!({"contents": [{"role":"user","parts":[{"text":"hi"}]}], + "tools":[{"functionDeclarations":[{"name":"fabric_exec","parametersJsonSchema":schema}]}]}), + ), + ( + "claude:messages", + json!({"messages":[{"role":"user","content":"hi"}],"max_tokens":128, + "tools":[{"name":"fabric_exec","input_schema":schema}]}), + ), + ( + "openai:chat", + json!({"messages":[{"role":"user","content":"hi"}], + "tools":[{"type":"function","function":{"name":"fabric_exec","parameters":schema}}]}), + ), + ( + "openai:responses", + json!({"input":"hi", + "tools":[{"type":"function","name":"fabric_exec","parameters":schema}]}), + ), + ]; + for (format, body) in clients { + for model in ["claude-opus-4-6-thinking", "gemini-test"] { + let converted = build_standard_request_body( + &body, + format, + model, + "antigravity", + "gemini:generate_content", + "", + true, + None, + None, + ) + .unwrap(); + for request in [converted.clone(), json!({"request":converted})] { + let AntigravityRequestEnvelopeSupport::Supported(output) = + build_antigravity_safe_v1internal_request( + &sample_auth(), + "union-regression", + model, + &request, + AntigravityEnvelopeRequestType::Agent, + ) + else { + panic!("failed {format} {model}"); + }; + let s = &output["request"]["tools"][0]["functionDeclarations"][0]["parameters"]; + assert_eq!(s["required"], json!(["code"])); + assert_eq!( + s["properties"]["payloads"]["additionalProperties"], + json!({"type":"string"}) + ); + assert_eq!(s["properties"]["tokenBudget"]["minimum"], 1); + if model.starts_with("claude-") { + assert_eq!( + s["properties"]["resultFormat"], + json!({"type":"string","enum":["auto","json","text","yaml"]}) + ); + let display = &s["properties"]["display"]; + assert!( + display.get("type").is_none(), + "do not select one union branch" + ); + assert!(display.get("anyOf").is_none()); + assert!(display["description"].as_str().unwrap().contains("object")); + assert!(display["description"].as_str().unwrap().contains("string")); + } else { + assert!(s["properties"]["resultFormat"]["anyOf"].is_array()); + assert!(s["properties"]["display"]["anyOf"].is_array()); + } + let AntigravityRequestEnvelopeSupport::Supported(twice) = + build_antigravity_safe_v1internal_request( + &sample_auth(), + "union-regression", + model, + &output, + AntigravityEnvelopeRequestType::Agent, + ) + else { + panic!("idempotence"); + }; + assert_eq!(twice, output); + } + } + } + } + + #[test] + fn antigravity_claude_omits_only_unsigned_thought_parts() { + let signed = json!({"text":"signed plan","thought":true,"thoughtSignature":"signed-value"}); + let signed_alias = + json!({"text":"signed alias","thought":true,"thought_signature":"alias-value"}); + let call = json!({"functionCall":{"id":"call_1","name":"lookup","args":{}},"thoughtSignature":"skip_thought_signature_validator"}); + let result = json!({"role":"user","parts":[{"functionResponse":{"id":"call_1","name":"lookup","response":{"result":"ok"}}}]}); + let body = json!({ + "contents":[ + {"role":"user","parts":[{"text":"hello"}]}, + {"role":"model","parts":[{"text":"unsigned-only summary","thought":true}]}, + {"role":"model","parts":[ + {"text":"unsigned summary","thought":true}, + {"text":"empty signature","thought":true,"thoughtSignature":""}, + {"text":"blank signature","thought":true,"thoughtSignature":" "}, + {"text":"non-string signature","thought":true,"thoughtSignature":12}, + signed,signed_alias,{"text":"visible answer"},call + ]}, + result + ], + "generationConfig":{"maxOutputTokens":64000,"thinkingConfig":{"includeThoughts":true,"thinkingBudget":4096}} + }); + for model in [ + "claude-sonnet-4-6", + "claude-opus-4-6-thinking", + "gemini-3.8-flash-high", + ] { + for wrapped in [false, true] { + let input = if wrapped { + json!({"request":body}) + } else { + body.clone() + }; + let original = input.clone(); + let AntigravityRequestEnvelopeSupport::Supported(output) = + build_antigravity_safe_v1internal_request( + &sample_auth(), + "thought-test", + model, + &input, + AntigravityEnvelopeRequestType::Agent, + ) + else { + panic!("expected supported envelope"); + }; + let expected = if model.starts_with("claude-") { + json!([ + {"role":"user","parts":[{"text":"hello"}]}, + {"role":"model","parts":[signed,signed_alias,{"text":"visible answer"},call]}, + result + ]) + } else { + body["contents"].clone() + }; + assert_eq!( + output["request"]["contents"], expected, + "{model} wrapped={wrapped}" + ); + assert_eq!( + output["request"]["generationConfig"], + body["generationConfig"] + ); + assert_eq!(input, original, "do not mutate caller-owned input"); + let rebuilt = build_antigravity_safe_v1internal_request( + &sample_auth(), + "thought-test", + model, + &output, + AntigravityEnvelopeRequestType::Agent, + ); + assert_eq!( + rebuilt, + AntigravityRequestEnvelopeSupport::Supported(output) + ); + } + } + } + + #[test] + fn antigravity_claude_cross_format_unsigned_reasoning_history() { + use aether_ai_formats::formats::shared::standard_matrix::build_standard_request_body; + let clients = [ + ( + "openai:responses", + json!({ + "max_output_tokens":64000, + "input":[ + {"role":"user","content":"hello"}, + {"type":"reasoning","id":"rs_history","status":"completed","summary":[{"type":"summary_text","text":"historical summary"}],"content":[]}, + {"role":"assistant","content":[{"type":"output_text","text":"visible answer"}]}, + {"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"}, + {"type":"function_call_output","call_id":"call_1","output":"ok"}, + {"role":"user","content":"continue"} + ] + }), + ), + ( + "claude:messages", + json!({ + "max_tokens":64000, + "messages":[ + {"role":"user","content":"hello"}, + {"role":"assistant","content":[ + {"type":"thinking","thinking":"historical summary"}, + {"type":"text","text":"visible answer"}, + {"type":"tool_use","id":"call_1","name":"lookup","input":{}} + ]}, + {"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"ok"},{"type":"text","text":"continue"}]} + ] + }), + ), + ]; + for (format, body) in clients { + for model in [ + "claude-sonnet-4-6", + "claude-opus-4-6-thinking", + "gemini-3.8-flash-high", + ] { + let converted = build_standard_request_body( + &body, + format, + model, + "antigravity", + "gemini:generate_content", + "", + true, + None, + None, + ) + .unwrap(); + assert!( + converted["contents"] + .as_array() + .unwrap() + .iter() + .flat_map(|m| m["parts"].as_array().unwrap()) + .any(|p| p["thought"] == true), + "fixture must exercise unsigned thoughts: {format}" + ); + let AntigravityRequestEnvelopeSupport::Supported(output) = + build_antigravity_safe_v1internal_request( + &sample_auth(), + "reasoning-history", + model, + &converted, + AntigravityEnvelopeRequestType::Agent, + ) + else { + panic!("expected supported envelope"); + }; + let messages = output["request"]["contents"].as_array().unwrap(); + assert!(messages + .iter() + .all(|m| !m["parts"].as_array().unwrap().is_empty())); + let parts: Vec<_> = messages + .iter() + .flat_map(|m| m["parts"].as_array().unwrap()) + .collect(); + assert_eq!( + parts.iter().any(|p| p["thought"] == true), + !model.starts_with("claude-") + ); + assert!(parts.iter().any(|p| p["text"] == "visible answer")); + assert!(parts.iter().any(|p| p["text"] == "continue")); + assert!(parts.iter().any(|p| p["functionCall"]["id"] == "call_1" + && p["functionCall"]["name"] == "lookup")); + assert!(parts.iter().any(|p| p["functionResponse"]["id"] == "call_1" + && p["functionResponse"]["name"] == "lookup")); + if model.starts_with("claude-") { + assert!( + !parts.iter().any(|p| p["text"] == "historical summary"), + "do not promote private reasoning to visible text" + ); + } + assert_eq!( + output["request"]["generationConfig"]["maxOutputTokens"], + 64000 + ); + } + } + } + fn sample_auth() -> AntigravityRequestAuth { AntigravityRequestAuth { project_id: "project-ant-123".to_string(), @@ -224,6 +557,199 @@ mod tests { } } + #[test] + fn antigravity_combined_client_conversion_preserves_schemas_until_transport() { + use aether_ai_formats::formats::shared::standard_matrix::build_standard_request_body; + let schema = json!({"type": "object", "properties": { + "mode": {"const": "fast"}, + "payloads": {"type": "object", "patternProperties": {"^.*$": {"type": "string"}}}, + "name": {"type": "string", "minLength": 1} + }, "required": ["mode"]}); + let clients = [ + ( + "claude:messages", + json!({"model": "client-model", "max_tokens": 128, + "messages": [{"role": "user", "content": "hi"}], + "tools": [{"name": "probe", "input_schema": schema}]}), + ), + ( + "openai:chat", + json!({"model": "client-model", + "messages": [{"role": "user", "content": "hi"}], + "tools": [{"type": "function", "function": {"name": "probe", "parameters": schema}}]}), + ), + ( + "openai:responses", + json!({"model": "client-model", "input": "hi", + "tools": [{"type": "function", "name": "probe", "parameters": schema}]}), + ), + ( + "gemini:generate_content", + json!({"model": "client-model", + "contents": [{"role": "user", "parts": [{"text": "hi"}]}], + "tools": [{"functionDeclarations": [{"name": "probe", "parameters": schema}]}]}), + ), + ]; + for (source, original) in clients { + for model in ["claude-sonnet-test", "gemini-test"] { + let converted = build_standard_request_body( + &original, + source, + model, + " AnTiGrAvItY ", + "gemini:generate_content", + "", + true, + None, + None, + ) + .expect("Antigravity conversion"); + assert_eq!( + converted["tools"][0]["functionDeclarations"][0]["parameters"], schema, + "{source}" + ); + let AntigravityRequestEnvelopeSupport::Supported(envelope) = + build_antigravity_safe_v1internal_request( + &sample_auth(), + "review-test", + model, + &converted, + AntigravityEnvelopeRequestType::Agent, + ) + else { + panic!("schema should fit budget"); + }; + let parameters = + &envelope["request"]["tools"][0]["functionDeclarations"][0]["parameters"]; + assert_eq!( + parameters["properties"]["mode"], + json!({"type": "string", "enum": ["fast"]}) + ); + assert_eq!( + parameters["properties"]["payloads"]["additionalProperties"], + json!({"type": "string"}) + ); + assert_eq!(parameters["properties"]["name"]["minLength"], 1); + assert_eq!(envelope["model"], model); + } + // The default public Gemini policy must remain unchanged. + let public = build_standard_request_body( + &original, + source, + "gemini-test", + "gemini", + "gemini:generate_content", + "", + true, + None, + None, + ) + .unwrap(); + let parameters = &public["tools"][0]["functionDeclarations"][0]["parameters"]; + if source == "gemini:generate_content" { + assert_eq!(parameters, &schema); + } else { + assert!(parameters["properties"]["mode"].get("const").is_none()); + assert_eq!(parameters["properties"]["name"]["minLength"], "1"); + } + } + } + + #[test] + fn antigravity_responses_conversion_keeps_scoped_previous_response_history() { + use aether_ai_formats::{ + api::record_converted_response_history, + formats::shared::standard_matrix::build_standard_request_body, + }; + let schema = json!({"type": "object", "properties": {"mode": {"const": "fast"}}}); + let response_id = "resp_antigravity_schema_history_test"; + let scope = "antigravity-schema-history"; + record_converted_response_history(&json!({ + "needs_conversion": true, "client_api_format": "openai:responses", + "provider_api_format": "openai:chat", "api_key_id": scope, + "original_request_body": {"model": "client", "input": "first"} + }), &json!({"id": response_id, "status": "completed", "output": [{ + "type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "remembered"}] + }]})).expect("seed scoped history"); + let input = json!({"model": "client", "previous_response_id": response_id, + "input": "second", "tools": [{"type": "function", "name": "probe", "parameters": schema}]}); + let output = build_standard_request_body( + &input, + "openai:responses", + "claude-test", + "antigravity", + "gemini:generate_content", + "", + true, + None, + Some(scope), + ) + .expect("expand history"); + assert_eq!(output["contents"][0]["parts"][0]["text"], "first"); + assert_eq!(output["contents"][1]["parts"][0]["text"], "remembered"); + assert_eq!(output["contents"][2]["parts"][0]["text"], "second"); + assert_eq!( + output["tools"][0]["functionDeclarations"][0]["parameters"], + schema + ); + assert!(build_standard_request_body( + &input, + "openai:responses", + "claude-test", + "antigravity", + "gemini:generate_content", + "", + true, + None, + Some("different-key") + ) + .is_none()); + } + + #[test] + fn antigravity_rejects_shared_schema_budget_exhaustion_on_both_envelope_paths() { + use super::AntigravityRequestEnvelopeUnsupportedReason; + let declaration = json!({"name": "probe", "parameters": { + "type": "object", "description": "x".repeat(600_000) + }}); + let mut body = json!({"contents": [], "tools": [{"functionDeclarations": [declaration]}]}); + assert!(matches!( + build_antigravity_safe_v1internal_request( + &sample_auth(), + "test", + "claude-test", + &body, + AntigravityEnvelopeRequestType::Agent, + ), + AntigravityRequestEnvelopeSupport::Supported(_) + )); + body["tools"][0]["functionDeclarations"] + .as_array_mut() + .unwrap() + .push(declaration); + for wrapped in [false, true] { + let input = if wrapped { + json!({"request": body}) + } else { + body.clone() + }; + let snapshot = input.clone(); + assert_eq!( + build_antigravity_safe_v1internal_request( + &sample_auth(), + "test", + "claude-test", + &input, + AntigravityEnvelopeRequestType::Agent, + ), + AntigravityRequestEnvelopeSupport::Unsupported( + AntigravityRequestEnvelopeUnsupportedReason::ToolSchemaBudgetExceeded + ) + ); + assert_eq!(input, snapshot); + } + } + #[test] fn search_only_request_keeps_the_modern_google_search_spelling() { // Reproduces the live failure: a grounding-only request (no function @@ -516,6 +1042,162 @@ mod tests { ); } + #[test] + fn antigravity_envelope_downgrades_fabric_schema_on_all_input_paths() { + let schema = json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "code": { "type": "string", "description": "TypeScript function body" }, + "payloads": { + "type": "object", + "patternProperties": { "^.*$": { "type": "string" } } + }, + "resultFormat": { + "anyOf": [ + { "type": "string", "const": "auto" }, + { "type": "string", "const": "yaml" }, + { "type": "string", "const": "json" }, + { "type": "string", "const": "text" } + ] + }, + "display": { + "anyOf": [ + { "type": "object", "properties": { "name": { "type": "string" } } }, + { "type": "string" } + ] + }, + "tokenBudget": { "type": "number", "minimum": 1 } + }, + "required": ["code"], + "minProperties": "1" + }); + let mut expected = schema.clone(); + expected.as_object_mut().unwrap().remove("$schema"); + expected["minProperties"] = json!(1); + expected["properties"]["payloads"] = json!({ + "type": "object", "additionalProperties": { "type": "string" } + }); + for (index, format) in ["auto", "yaml", "json", "text"].iter().enumerate() { + expected["properties"]["resultFormat"]["anyOf"][index] = + json!({ "type": "string", "enum": [format] }); + } + + for declarations_key in ["functionDeclarations", "function_declarations"] { + for parameters_key in [ + "parametersJsonSchema", + "parameters_json_schema", + "parameters", + ] { + for wrapped in [false, true] { + let mut body = json!({ + "contents": [{ "role": "user", "parts": [{ "text": "hello" }] }], + "tools": [{ "googleSearch": {} }, {}], + "generationConfig": { "maxOutputTokens": 4096 }, + "labels": { "const": "not a schema" } + }); + body["tools"][1][declarations_key] = json!([ + { "name": "fabric_exec", "description": "Execute TypeScript" }, + { "name": "other", "parameters": { "type": "object" } } + ]); + body["tools"][1][declarations_key][0][parameters_key] = schema.clone(); + if wrapped { + body = json!({ "request": body, "requestId": "client-id" }); + } + let original = body.clone(); + let AntigravityRequestEnvelopeSupport::Supported(envelope) = + build_antigravity_safe_v1internal_request( + &sample_auth(), + "trace-id", + "gemini-test", + &body, + AntigravityEnvelopeRequestType::Agent, + ) + else { + panic!("Fabric request should be supported"); + }; + let declaration = &envelope["request"]["tools"][1][declarations_key][0]; + assert_eq!( + declaration["parameters"], expected, + "{declarations_key}/{parameters_key}/wrapped={wrapped}" + ); + assert_eq!(declaration["name"], "fabric_exec"); + assert!(declaration.get("parametersJsonSchema").is_none()); + assert!(declaration.get("parameters_json_schema").is_none()); + assert_eq!( + envelope["request"]["generationConfig"]["maxOutputTokens"], + 4096 + ); + assert_eq!(envelope["request"]["labels"]["const"], "not a schema"); + assert_eq!( + envelope["request"]["tools"][0], + json!({ "googleSearch": {} }) + ); + assert_eq!( + envelope["requestId"], + if wrapped { "client-id" } else { "trace-id" } + ); + assert_eq!(envelope["project"], "project-ant-123"); + assert_eq!(body, original, "caller-owned input must remain unchanged"); + + let AntigravityRequestEnvelopeSupport::Supported(rebuilt) = + build_antigravity_safe_v1internal_request( + &sample_auth(), + "trace-id", + "gemini-test", + &envelope, + AntigravityEnvelopeRequestType::Agent, + ) + else { + panic!("converted envelope should be supported"); + }; + assert_eq!(rebuilt, envelope, "normalization must be idempotent"); + } + } + } + } + + #[test] + fn antigravity_schema_aliases_do_not_override_existing_parameters() { + let body = json!({ + "contents": [], + "tools": [{ "functionDeclarations": [{ + "name": "select", + "parameters": { "type": "string", "const": "existing" }, + "parametersJsonSchema": { "type": "string", "const": "camel" }, + "parameters_json_schema": { "type": "string", "const": "snake" } + }, { + "name": "select_alias", + "parametersJsonSchema": { "type": "string", "const": "camel" }, + "parameters_json_schema": { "type": "string", "const": "snake" } + }] }] + }); + let AntigravityRequestEnvelopeSupport::Supported(envelope) = + build_antigravity_safe_v1internal_request( + &sample_auth(), + "trace-id", + "gemini-test", + &body, + AntigravityEnvelopeRequestType::Agent, + ) + else { + panic!("request should be supported"); + }; + let declarations = &envelope["request"]["tools"][0]["functionDeclarations"]; + assert_eq!( + declarations[0]["parameters"], + json!({ "type": "string", "enum": ["existing"] }) + ); + assert_eq!( + declarations[1]["parameters"], + json!({ "type": "string", "enum": ["camel"] }) + ); + for declaration in declarations.as_array().unwrap() { + assert!(declaration.get("parametersJsonSchema").is_none()); + assert!(declaration.get("parameters_json_schema").is_none()); + } + } + #[test] fn antigravity_envelope_normalizes_json_schema_parameter_spellings() { let request_body = json!({ diff --git a/crates/aether-provider/transport/src/antigravity/schema.rs b/crates/aether-provider/transport/src/antigravity/schema.rs new file mode 100644 index 000000000..fee8790f3 --- /dev/null +++ b/crates/aether-provider/transport/src/antigravity/schema.rs @@ -0,0 +1,789 @@ +use std::collections::BTreeSet; + +use serde_json::{json, Map, Value}; + +/// Cloud Code's `parameters` uses a protobuf-shaped schema, while its Claude +/// backend validates the translated `tools.*.custom.input_schema` as JSON Schema +/// draft 2020-12. Keep this lowering at the Antigravity boundary: it preserves the +/// common subset and converts protobuf JSON int64 strings back to JSON numbers. +/// Callers must still validate tool arguments against their original schema. +pub(super) fn normalize_tool_parameters( + parameters: &mut Value, + budget: &mut SchemaBudget, +) -> Result<(), ()> { + let lowered = lower_schema(parameters, parameters, &mut BTreeSet::new(), 0, budget); + if budget.exhausted { + return Err(()); + } + *parameters = lowered; + Ok(()) +} + +/// Cloud Code accepts these unions but its Claude bridge rejects typed anyOf +/// branches (verified with the real fabric_exec schema). Do not apply this to +/// Gemini models. Fold string literal alternatives exactly; otherwise relax the +/// union and retain its constraints as guidance, never choose an arbitrary branch. +/// The caller must validate generated arguments against the original schema. +pub(super) fn normalize_claude_unions( + schema: &mut Value, + budget: &mut SchemaBudget, +) -> Result<(), ()> { + lower_claude_unions(schema, budget) +} + +fn lower_claude_unions(value: &mut Value, budget: &mut SchemaBudget) -> Result<(), ()> { + let Some(schema) = value.as_object_mut() else { + return Ok(()); + }; + if let Some(Value::Array(branches)) = schema.remove("anyOf") { + // Only intersect an existing sibling enum when both sides are strings; + // an empty intersection cannot be represented by protobuf enum (omitted). + let literals = string_union_literals(&branches); + let folded = literals.and_then(|mut literals| { + if let Some(Value::Array(existing)) = schema.get("enum") { + literals.retain(|literal| existing.contains(&Value::String(literal.clone()))); + } + (!literals.is_empty()).then_some(literals) + }); + if let Some(literals) = folded { + schema.entry("type").or_insert(json!("string")); + schema.insert("enum".into(), json!(literals)); + } else { + let guidance = serde_json::to_string(&branches).expect("JSON value serializes"); + let description = schema.entry("description").or_insert(json!("")); + let original = description.as_str().unwrap_or_default(); + *description = json!(format!("{original}\nAccepted alternatives (validate against the original tool schema): {guidance}").trim()); + if !budget.charge(description) { + return Err(()); + } + } + } + // Traverse schema positions only: descriptions/defaults/examples and property + // names such as `anyOf` are data, not keywords to rewrite. + if let Some(Value::Object(properties)) = schema.get_mut("properties") { + for child in properties.values_mut() { + lower_claude_unions(child, budget)?; + } + } + for key in ["items", "additionalProperties"] { + if let Some(child) = schema.get_mut(key) { + lower_claude_unions(child, budget)?; + } + } + Ok(()) +} + +fn string_union_literals(branches: &[Value]) -> Option> { + if branches.is_empty() { + return None; + } + let mut literals = BTreeSet::new(); + for branch in branches { + let branch = branch.as_object()?; + if branch + .keys() + .any(|key| !matches!(key.as_str(), "type" | "enum" | "description" | "title")) + || branch.get("type").is_some_and(|ty| ty != "string") + { + return None; + } + let values = branch.get("enum")?.as_array()?; + if values.is_empty() { + return None; + } + for literal in values { + literals.insert(literal.as_str()?.to_owned()); + } + } + Some(literals) +} + +/// Shared across all tool schemas in one request. Counting serialized input at +/// every expansion conservatively bounds cloning work, including literal data +/// and repeated acyclic references, without allocating serialized copies. +pub(super) struct SchemaBudget { + nodes_left: usize, + bytes_left: usize, + exhausted: bool, +} + +impl Default for SchemaBudget { + fn default() -> Self { + Self { + nodes_left: 4096, + bytes_left: 1024 * 1024, + exhausted: false, + } + } +} + +impl SchemaBudget { + fn charge(&mut self, value: &Value) -> bool { + if self.exhausted || self.nodes_left == 0 { + self.exhausted = true; + return false; + } + self.nodes_left -= 1; + if serde_json::to_writer(&mut *self, value).is_err() { + self.exhausted = true; + return false; + } + true + } +} + +impl std::io::Write for SchemaBudget { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if bytes.len() > self.bytes_left { + return Err(std::io::Error::other( + "tool schema expansion budget exceeded", + )); + } + self.bytes_left -= bytes.len(); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// JSON Schema $ref siblings are conjunctive, not an object-spread override. +/// Merge properties and required sets; retain the referenced constraint when +/// two validation keywords cannot be intersected in the wire subset. This +/// relaxes validation rather than manufacturing a contradictory schema. +fn merge_ref_siblings(mut target: Value, siblings: Value) -> Value { + let target_object = target.as_object_mut().expect("lowered schema is an object"); + let Value::Object(siblings) = siblings else { + unreachable!("lowered schema is an object") + }; + for (key, value) in siblings { + match (key.as_str(), target_object.get_mut(&key), value) { + ("properties", Some(Value::Object(properties)), Value::Object(children)) => { + for (name, child) in children { + if let Some(existing) = properties.get_mut(&name) { + *existing = merge_ref_siblings(existing.take(), child); + } else { + properties.insert(name, child); + } + } + } + ("required", Some(Value::Array(required)), Value::Array(names)) => { + let mut seen: BTreeSet = required + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(); + for name in names { + if seen.insert(name.as_str().expect("required contains strings").to_owned()) { + required.push(name); + } + } + } + ("title" | "description" | "default" | "example", _, value) | (_, None, value) => { + target_object.insert(key, value); + } + _ => {} + } + } + target +} + +fn lower_schema( + value: &Value, + root: &Value, + resolving: &mut BTreeSet, + depth: usize, + budget: &mut SchemaBudget, +) -> Value { + // Bound both recursive references and expansion of deeply nested schemas. + if !budget.charge(value) || depth >= 64 { + return json!({}); + } + let Some(source) = value.as_object() else { + // Boolean schemas have no protobuf equivalent (false is relaxed). + return json!({}); + }; + let mut source = source.clone(); + if let Some(Value::String(reference)) = source.remove("$ref") { + if let Some(target) = reference + .strip_prefix('#') + .and_then(|pointer| root.pointer(pointer)) + .filter(|target| target.is_object()) + { + if resolving.insert(reference.clone()) { + let target = lower_schema(target, root, resolving, depth + 1, budget); + resolving.remove(&reference); + let siblings = + lower_schema(&Value::Object(source), root, resolving, depth + 1, budget); + return merge_ref_siblings(target, siblings); + } + } + // Unresolved, external or cyclic refs retain only their sibling fields. + } + + let mut schema = Map::new(); + // Keep only a typed JSON-Schema subset. Gemini's protobuf JSON mapping renders + // int64 constraints as strings, but Claude rejects those at custom.input_schema. + match source.get("type") { + Some(Value::String(schema_type)) => { + if let Some(schema_type) = json_schema_type(schema_type) { + schema.insert("type".to_string(), Value::String(schema_type.to_string())); + } + } + Some(Value::Array(types)) => { + schema.insert("type".to_string(), Value::Array(types.clone())); + } + _ => {} + } + for key in ["format", "title", "description", "pattern"] { + if let Some(value) = source.get(key).filter(|value| value.is_string()) { + schema.insert(key.to_string(), value.clone()); + } + } + if let Some(value) = source.get("nullable").filter(|value| value.is_boolean()) { + schema.insert("nullable".to_string(), value.clone()); + } + if let Some(values) = json_schema_string_array(source.get("enum"), true) { + schema.insert("enum".to_string(), values); + } + for key in ["minimum", "maximum"] { + if let Some(value) = source.get(key).filter(|value| value.is_number()) { + schema.insert(key.to_string(), value.clone()); + } + } + for key in [ + "minItems", + "maxItems", + "minLength", + "maxLength", + "minProperties", + "maxProperties", + ] { + if let Some(value) = source.get(key).and_then(json_schema_nonnegative_integer) { + schema.insert(key.to_string(), value); + } + } + if let Some(values) = json_schema_string_array(source.get("required"), false) { + schema.insert("required".to_string(), values); + } + if let Some(values) = json_schema_string_array(source.get("propertyOrdering"), false) { + schema.insert("propertyOrdering".to_string(), values); + } + for key in ["default", "example"] { + if let Some(value) = source.get(key) { + schema.insert(key.to_string(), value.clone()); + } + } + + if let Some(constant) = source.get("const") { + if constant.is_string() { + schema.insert("enum".to_string(), json!([constant])); + schema.entry("type").or_insert(json!("string")); + } else { + // The protobuf enum field is repeated string. Never stringify numeric + // or boolean literals into enums: that changes the argument's type. + let description = schema.entry("description").or_insert(json!("")); + let prefix = description.as_str().unwrap_or_default(); + *description = json!(format!("{prefix}\nMust equal: {constant}").trim()); + } + } + if let Some(values) = schema.get_mut("enum").and_then(Value::as_array_mut) { + // Mixed/non-string enums cannot be represented without changing types. + if !values.iter().all(Value::is_string) { + schema.remove("enum"); + } + } + + if let Some(properties) = source.get("properties").and_then(Value::as_object) { + schema.insert( + "properties".to_string(), + Value::Object( + properties + .iter() + .map(|(name, child)| { + ( + name.clone(), + lower_schema(child, root, resolving, depth + 1, budget), + ) + }) + .collect(), + ), + ); + } + if let Some(items) = source.get("items") { + schema.insert( + "items".to_string(), + lower_schema(items, root, resolving, depth + 1, budget), + ); + } + // oneOf's exclusivity is not supported; anyOf retains the alternatives. + if let Some(branches) = source + .get("anyOf") + .or_else(|| source.get("any_of")) + .or_else(|| source.get("oneOf")) + .and_then(Value::as_array) + { + if !branches.is_empty() { + schema.insert( + "anyOf".to_string(), + Value::Array( + branches + .iter() + .map(|child| lower_schema(child, root, resolving, depth + 1, budget)) + .collect(), + ), + ); + } + } + + let patterns = source.get("patternProperties").and_then(Value::as_object); + let wildcard = patterns + .filter(|patterns| patterns.len() == 1) + .and_then(|patterns| { + patterns + .iter() + .next() + .filter(|(pattern, _)| matches!(pattern.as_str(), ".*" | "^.*$")) + }) + .map(|(_, child)| child); + // For a catch-all, JSON Schema's additionalProperties applies to no unmatched + // keys, so even an explicit `false` must not suppress the dictionary values. + let additional = wildcard.or_else(|| { + // Without regex matching, an additional-properties constraint could + // incorrectly reject keys formerly accepted by a pattern. Relax it too. + patterns + .is_none_or(Map::is_empty) + .then(|| source.get("additionalProperties")) + .flatten() + }); + if let Some(additional) = additional { + if additional.is_boolean() { + // Dropping non-wildcard patterns must not turn their allowed keys into + // forbidden additional properties. General regex maps are relaxed. + if additional != &Value::Bool(false) || patterns.is_none_or(Map::is_empty) { + schema.insert("additionalProperties".to_string(), additional.clone()); + } + } else { + schema.insert( + "additionalProperties".to_string(), + lower_schema(additional, root, resolving, depth + 1, budget), + ); + } + } + + if let Some(Value::Array(types)) = schema.get("type").cloned() { + schema.remove("type"); + let nullable = types.iter().any(|value| value.as_str() == Some("null")); + let types: BTreeSet<_> = types + .iter() + .filter_map(Value::as_str) + .filter_map(json_schema_type) + .filter(|value| *value != "null") + .collect(); + if types.len() == 1 { + schema.insert("type".to_string(), json!(types.first().unwrap())); + } else if !types.is_empty() { + schema.entry("anyOf").or_insert_with(|| { + Value::Array(types.iter().map(|ty| json!({ "type": ty })).collect()) + }); + } else if nullable { + schema.insert("type".to_string(), json!("null")); + } + if nullable && !types.is_empty() { + schema.insert("nullable".to_string(), Value::Bool(true)); + } + } + Value::Object(schema) +} + +fn json_schema_type(value: &str) -> Option<&'static str> { + match value.trim().to_ascii_lowercase().as_str() { + "array" => Some("array"), + "boolean" => Some("boolean"), + "integer" => Some("integer"), + "null" => Some("null"), + "number" => Some("number"), + "object" => Some("object"), + "string" => Some("string"), + _ => None, + } +} + +fn json_schema_nonnegative_integer(value: &Value) -> Option { + let value = match value { + Value::Number(value) => value.as_u64(), + // protobuf JSON encodes int64 fields as decimal strings. + Value::String(value) => value.parse::().ok(), + _ => None, + }?; + Some(Value::from(value)) +} + +fn json_schema_string_array(value: Option<&Value>, require_non_empty: bool) -> Option { + let values = value?.as_array()?; + let mut seen = BTreeSet::new(); + let values = values + .iter() + .map(Value::as_str) + .collect::>>()? + .into_iter() + .filter(|value| seen.insert(*value)) + .map(|value| Value::String(value.to_string())) + .collect::>(); + (!require_non_empty || !values.is_empty()).then_some(Value::Array(values)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn claude_unions_keep_siblings_literals_and_nested_schema_positions() { + let union = json!({"anyOf":[{"type":"string"},{"type":"object"}]}); + let mut schema = json!({"type":"object", "default":union, "properties":{ + "anyOf":{"type":"array","items":union}, + "map":{"type":"object","additionalProperties":union}, + "literal":{"enum":["b"],"anyOf":[{"type":"string","enum":["a"]},{"type":"string","enum":["b"]}]}, + "constrained":{"description":"Original","type":"string","minLength":2,"anyOf":[{"type":"string","pattern":"a+"},{"type":"number"}]} + }}); + normalize_claude_unions(&mut schema, &mut SchemaBudget::default()).unwrap(); + assert_eq!( + schema["default"], union, + "literal data must not be rewritten" + ); + assert!(schema["properties"]["anyOf"]["items"]["description"].is_string()); + assert!(schema["properties"]["map"]["additionalProperties"]["description"].is_string()); + assert_eq!( + schema["properties"]["literal"], + json!({"type":"string","enum":["b"]}) + ); + let constrained = &schema["properties"]["constrained"]; + assert_eq!(constrained["minLength"], 2); + assert_eq!(constrained["type"], "string"); + assert!(constrained["description"] + .as_str() + .unwrap() + .starts_with("Original")); + assert!(constrained["description"] + .as_str() + .unwrap() + .contains("pattern")); + let once = schema.clone(); + normalize_claude_unions(&mut schema, &mut SchemaBudget::default()).unwrap(); + assert_eq!(schema, once); + } + + #[test] + fn claude_union_guidance_is_charged_to_shared_budget() { + let mut schema = json!({"anyOf":[{"type":"string"},{"type":"number"}]}); + let mut budget = SchemaBudget { + nodes_left: 4096, + bytes_left: 8, + exhausted: false, + }; + assert!(normalize_claude_unions(&mut schema, &mut budget).is_err()); + assert!(budget.exhausted); + } + + #[test] + fn claude_union_folding_does_not_drop_branch_constraints() { + let mut schema = json!({"anyOf":[ + {"type":"string","enum":["a"],"minLength":2}, + {"type":"string","enum":["b"]} + ]}); + normalize_claude_unions(&mut schema, &mut SchemaBudget::default()).unwrap(); + assert!(schema.get("enum").is_none()); + assert!(schema["description"] + .as_str() + .unwrap() + .contains("minLength")); + } + + fn lowered(mut schema: Value) -> Value { + normalize_tool_parameters(&mut schema, &mut SchemaBudget::default()).unwrap(); + let once = schema.clone(); + normalize_tool_parameters(&mut schema, &mut SchemaBudget::default()).unwrap(); + assert_eq!(schema, once, "lowering must be idempotent"); + schema + } + + #[test] + fn reference_siblings_preserve_properties_and_required_without_contradictions() { + let result = lowered(json!({ + "$defs": {"Base": { + "type": "object", "properties": {"a": {"type": "string", "minLength": 1}}, + "required": ["a"], "additionalProperties": false + }}, + "$ref": "#/$defs/Base", + "properties": {"a": {"maxLength": 8}, "b": {"type": "string"}}, + "required": ["a"] + })); + assert_eq!( + result, + json!({ + "type": "object", "properties": { + "a": {"type": "string", "minLength": 1, "maxLength": 8}, + "b": {"type": "string"} + }, "required": ["a"], "additionalProperties": false + }) + ); + // A property schema named "additionalProperties" is data, not a keyword. + let merged = merge_ref_siblings( + json!({"properties": {"additionalProperties": {"type": "string"}}, "required": ["a"]}), + json!({"properties": {"b": {"type": "number"}}, "required": ["b", "a"]}), + ); + assert_eq!(merged["required"], json!(["a", "b"])); + assert_eq!( + merged["properties"]["additionalProperties"]["type"], + "string" + ); + } + + #[test] + fn sibling_reference_to_completed_target_is_not_a_cycle() { + let result = lowered(json!({ + "$defs": {"Base": {"type": "object", "properties": {"mode": {"const": "fast"}}}}, + "$ref": "#/$defs/Base", "properties": {"nested": {"$ref": "#/$defs/Base"}} + })); + assert_eq!( + result["properties"]["nested"]["properties"]["mode"], + json!({"type": "string", "enum": ["fast"]}) + ); + } + + #[test] + fn acyclic_branching_references_exhaust_budget_without_mutating_input() { + let mut schema = json!({"$defs": {"D0": {"type": "string"}}, "$ref": "#/$defs/D24"}); + for index in 1..=24 { + let reference = format!("#/$defs/D{}", index - 1); + schema["$defs"][format!("D{index}")] = json!({"type": "object", "properties": { + "left": {"$ref": reference}, "right": {"$ref": reference} + }}); + } + let original = schema.clone(); + let mut budget = SchemaBudget::default(); + assert!(normalize_tool_parameters(&mut schema, &mut budget).is_err()); + assert!(budget.exhausted); + assert_eq!(schema, original); + } + + #[test] + fn limits_nodes_and_literal_bytes_not_only_reference_depth() { + let mut wide = json!({"type": "object", "properties": {}}); + for index in 0..5000 { + wide["properties"][format!("p{index}")] = json!({}); + } + let mut budget = SchemaBudget::default(); + assert!(normalize_tool_parameters(&mut wide, &mut budget).is_err()); + assert_eq!(budget.nodes_left, 0); + let mut literal = json!({"type": "object", "default": "x".repeat(1024 * 1024)}); + assert!(normalize_tool_parameters(&mut literal, &mut SchemaBudget::default()).is_err()); + } + + #[test] + fn recursively_lowers_schema_nodes_without_touching_property_names_or_literal_data() { + let literal = json!({ "const": "data", "patternProperties": { "^.*$": 1 } }); + let result = lowered(json!({ + "$schema": "draft", "$id": "id", "x-custom": true, + "type": "object", "additionalProperties": false, + "properties": { + "const": { "const": "value", "readOnly": true }, + "patternProperties": { + "type": "array", "uniqueItems": true, + "items": { "oneOf": [{ "const": "a" }, { "const": "b" }] } + }, + "dictionary": { + "type": "object", + "additionalProperties": { + "any_of": [{ "const": "nested" }], "deprecated": true + } + } + }, + "default": literal, "example": literal, + "required": ["const"], "propertyOrdering": ["const", "patternProperties"] + })); + assert_eq!( + result, + json!({ + "type": "object", "additionalProperties": false, + "properties": { + "const": { "type": "string", "enum": ["value"] }, + "patternProperties": { + "type": "array", + "items": { "anyOf": [ + { "type": "string", "enum": ["a"] }, + { "type": "string", "enum": ["b"] } + ] } + }, + "dictionary": { + "type": "object", "additionalProperties": { + "anyOf": [{ "type": "string", "enum": ["nested"] }] + } + } + }, + "default": literal, "example": literal, + "required": ["const"], "propertyOrdering": ["const", "patternProperties"] + }) + ); + } + + #[test] + fn catch_all_patterns_preserve_dictionary_values_even_with_additional_properties_false() { + for pattern in [".*", "^.*$"] { + for additional in [json!(false), json!(true), json!({ "type": "number" })] { + let mut schema = json!({ "type": "object", "additionalProperties": additional }); + schema["patternProperties"][pattern] = json!({ "const": "value" }); + assert_eq!( + lowered(schema), + json!({ + "type": "object", "additionalProperties": { "type": "string", "enum": ["value"] } + }) + ); + } + } + } + + #[test] + fn general_patterns_are_not_mistaken_for_catch_all_dictionaries() { + for patterns in [ + json!({ "^x-": { "type": "string" } }), + json!({ "^.*$": { "type": "string" }, "^x-": { "maxLength": 5 } }), + ] { + for additional in [json!(false), json!({ "type": "number" })] { + assert_eq!( + lowered(json!({ + "type": "object", "patternProperties": patterns, + "additionalProperties": additional + })), + json!({ "type": "object" }) + ); + } + } + assert_eq!( + lowered(json!({ "type": "object", "additionalProperties": true })), + json!({ "type": "object", "additionalProperties": true }) + ); + } + + #[test] + fn resolves_local_references_with_siblings_and_terminates_cycles() { + let result = lowered(json!({ + "$defs": { + "Mode": { "const": "fast", "description": "original" }, + "Node": { "type": "object", "properties": { "next": { "$ref": "#/$defs/Node" } } } + }, + "type": "object", + "properties": { + "mode": { "$ref": "#/$defs/Mode", "description": "override" }, + "again": { "$ref": "#/$defs/Mode" }, + "node": { "$ref": "#/$defs/Node" }, + "missing": { "$ref": "#/$defs/Missing", "type": "string" }, + "external": { "$ref": "https://example.test/schema", "description": "external" } + } + })); + assert_eq!( + result["properties"]["mode"], + json!({ "type": "string", "enum": ["fast"], "description": "override" }) + ); + assert_eq!(result["properties"]["again"]["enum"], json!(["fast"])); + assert_eq!( + result["properties"]["node"]["properties"]["next"], + json!({}) + ); + assert_eq!(result["properties"]["missing"], json!({ "type": "string" })); + assert_eq!( + result["properties"]["external"], + json!({ "description": "external" }) + ); + assert!(result.get("$defs").is_none()); + } + + #[test] + fn lowers_nullable_type_unions_and_non_string_constants_without_invalid_enums() { + assert_eq!( + lowered(json!({ "type": ["string", "null", "string"] })), + json!({ "type": "string", "nullable": true }) + ); + assert_eq!( + lowered(json!({ "type": ["string", "number"] })), + json!({ "anyOf": [{ "type": "number" }, { "type": "string" }] }) + ); + for (ty, value) in [ + ("integer", json!(42)), + ("boolean", json!(true)), + ("null", Value::Null), + ] { + let result = lowered(json!({ "type": ty, "const": value })); + assert_eq!(result["type"], ty); + assert_eq!(result["description"], format!("Must equal: {value}")); + assert!(result.get("enum").is_none()); + assert!(result.get("const").is_none()); + } + assert_eq!( + lowered(json!({ "type": "integer", "enum": [1, 2] })), + json!({ "type": "integer" }) + ); + } + + #[test] + fn converts_protobuf_integer_strings_to_draft_2020_numbers() { + let result = lowered(json!({ + "type": ["OBJECT", "not-a-json-schema-type"], + "minProperties": "1", + "maxProperties": "invalid", + "required": ["args", "args"], + "enum": [], + "anyOf": [], + "properties": { + "args": { + "type": "ARRAY", + "minItems": "2", + "maxItems": "3", + "minLength": "4", + "maxLength": 8, + "minimum": "invalid", + "maximum": 1, + "items": { "type": "STRING", "minLength": "0" } + } + } + })); + assert_eq!( + result, + json!({ + "type": "object", + "minProperties": 1, + "required": ["args"], + "properties": { + "args": { + "type": "array", + "minItems": 2, + "maxItems": 3, + "minLength": 4, + "maxLength": 8, + "maximum": 1, + "items": { "type": "string", "minLength": 0 } + } + } + }) + ); + } + + #[test] + fn handles_boolean_and_deep_schemas_without_panicking() { + for input in [json!(true), json!(false), Value::Null] { + assert_eq!(lowered(input), json!({})); + } + let mut nested = json!({ "const": "deep" }); + for _ in 0..70 { + nested = json!({ "type": "array", "items": nested }); + } + let result = lowered(nested); + let mut child = &result; + for _ in 0..64 { + assert_eq!(child["type"], "array"); + child = &child["items"]; + } + assert_eq!(child, &json!({})); + } +} From 4124749a7d70e407062248ca186331dec1989cd9 Mon Sep 17 00:00:00 2001 From: wangpengxiang Date: Thu, 17 Sep 2026 11:28:42 +0800 Subject: [PATCH 21/72] fix(ai-serving): route provider-aware normalization through root seams Move Antigravity schema-preservation policy into the format crate and expose provider-aware Chat and Responses builders through the existing gateway root seam. Preserve legacy conversion and scoped Responses history behavior without weakening architecture tests. Validated: 61 standalone architecture tests, 947 format tests, 527 transport tests, 2 actual-source planner tests, and gateway cargo check. --- .../planner/standard/normalize/chat.rs | 28 +-- .../planner/standard/normalize/responses.rs | 31 +-- .../aether-gateway/src/ai_serving/pure/mod.rs | 2 + crates/aether-ai/formats/src/api.rs | 2 + .../src/formats/shared/standard_matrix.rs | 15 +- .../src/formats/shared/standard_normalize.rs | 211 ++++++++++++++++++ 6 files changed, 248 insertions(+), 41 deletions(-) diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/chat.rs b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/chat.rs index e0d44d283..5c2fbfe31 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/chat.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/chat.rs @@ -4,7 +4,7 @@ use crate::ai_serving::transport::apply_standard_provider_request_body_rules_wit use crate::ai_serving::{ apply_codex_openai_responses_chat_body_edits, apply_openai_responses_compact_special_body_edits, - build_cross_format_openai_chat_request_body_with_model_directives as surface_build_cross_format_openai_chat_request_body, + build_cross_format_openai_chat_request_body_with_provider_context as surface_build_cross_format_openai_chat_request_body, build_local_openai_chat_request_body_with_model_directives as surface_build_local_openai_chat_request_body, GatewayProviderTransportSnapshot, }; @@ -70,23 +70,15 @@ pub(crate) fn build_cross_format_openai_chat_request_body( request_headers: &http::HeaderMap, enable_model_directives: bool, ) -> Option { - let provider_request_body = if provider_type.trim().eq_ignore_ascii_case("antigravity") - && aether_ai_formats::normalize_api_format_alias(provider_api_format) - == "gemini:generate_content" - { - aether_ai_formats::formats::shared::standard_matrix::build_standard_request_body_with_model_directives( - body_json, "openai:chat", mapped_model, provider_type, provider_api_format, - "", upstream_is_stream, None, user_api_key_id, enable_model_directives, - )? - } else { - surface_build_cross_format_openai_chat_request_body( - body_json, - mapped_model, - provider_api_format, - upstream_is_stream, - enable_model_directives, - )? - }; + let provider_request_body = surface_build_cross_format_openai_chat_request_body( + body_json, + mapped_model, + provider_type, + provider_api_format, + upstream_is_stream, + enable_model_directives, + user_api_key_id, + )?; let mut provider_request_body = apply_standard_provider_request_body_rules_with_request_headers( provider_request_body, diff --git a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs index 724ec4a94..5a138ac83 100644 --- a/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs +++ b/apps/aether-gateway/src/ai_serving/planner/standard/normalize/responses.rs @@ -3,7 +3,7 @@ use serde_json::Value; use crate::ai_serving::transport::apply_standard_provider_request_body_rules_with_request_headers; use crate::ai_serving::{ apply_openai_responses_compact_special_body_edits, - build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope as surface_build_cross_format_openai_responses_request_body, + build_cross_format_openai_responses_request_body_with_provider_context as surface_build_cross_format_openai_responses_request_body, build_local_openai_responses_request_body_with_model_directives as surface_build_local_openai_responses_request_body, GatewayProviderTransportSnapshot, }; @@ -214,25 +214,16 @@ pub(crate) fn build_cross_format_openai_responses_request_body_with_codex_model_ model_capabilities: Option<&crate::ai_serving::CodexResponsesModelCapabilities>, enable_model_directives: bool, ) -> Option { - let provider_request_body = if provider_type.trim().eq_ignore_ascii_case("antigravity") - && aether_ai_formats::normalize_api_format_alias(provider_api_format) - == "gemini:generate_content" - { - aether_ai_formats::formats::shared::standard_matrix::build_standard_request_body_with_model_directives( - body_json, client_api_format, mapped_model, provider_type, provider_api_format, - "", upstream_is_stream, None, history_scope, enable_model_directives, - )? - } else { - surface_build_cross_format_openai_responses_request_body( - body_json, - mapped_model, - client_api_format, - provider_api_format, - upstream_is_stream, - enable_model_directives, - history_scope, - )? - }; + let provider_request_body = surface_build_cross_format_openai_responses_request_body( + body_json, + mapped_model, + client_api_format, + provider_type, + provider_api_format, + upstream_is_stream, + enable_model_directives, + history_scope, + )?; let mut provider_request_body = apply_standard_provider_request_body_rules_with_request_headers( provider_request_body, diff --git a/apps/aether-gateway/src/ai_serving/pure/mod.rs b/apps/aether-gateway/src/ai_serving/pure/mod.rs index 42c45f186..41542a1f2 100644 --- a/apps/aether-gateway/src/ai_serving/pure/mod.rs +++ b/apps/aether-gateway/src/ai_serving/pure/mod.rs @@ -17,9 +17,11 @@ pub(crate) use aether_ai_formats::api::{ build_codex_model_catalog_metadata, build_codex_openai_image_api_provider_request_body, build_core_error_body_for_client_format, build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_request_body_with_model_directives, + build_cross_format_openai_chat_request_body_with_provider_context, build_cross_format_openai_responses_request_body, build_cross_format_openai_responses_request_body_with_model_directives, build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope, + build_cross_format_openai_responses_request_body_with_provider_context, build_gemini_image_request_body_from_openai_image_request, build_gemini_image_response_from_openai_image_response, build_gemini_image_response_from_openai_responses_image_response, build_generated_tool_call_id, diff --git a/crates/aether-ai/formats/src/api.rs b/crates/aether-ai/formats/src/api.rs index 1f0a310bd..fbbd51933 100644 --- a/crates/aether-ai/formats/src/api.rs +++ b/crates/aether-ai/formats/src/api.rs @@ -224,9 +224,11 @@ pub use crate::formats::{ standard_normalize::{ build_cross_format_openai_chat_request_body, build_cross_format_openai_chat_request_body_with_model_directives, + build_cross_format_openai_chat_request_body_with_provider_context, build_cross_format_openai_responses_request_body, build_cross_format_openai_responses_request_body_with_model_directives, build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope, + build_cross_format_openai_responses_request_body_with_provider_context, build_local_openai_chat_request_body, build_local_openai_chat_request_body_with_model_directives, build_local_openai_responses_request_body, diff --git a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs index 29d5fbc93..9bb764655 100644 --- a/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/standard_matrix.rs @@ -23,6 +23,17 @@ use crate::formats::shared::standard_normalize::{ is_claude_messages_shaped_body_on_openai_chat_endpoint, }; +/// Tool schema preservation is a format-conversion policy, shared by the +/// standard matrix and provider-aware Chat/Responses entry points. +pub(super) fn preserves_gemini_tool_schemas( + provider_type: &str, + provider_api_format: &str, +) -> bool { + provider_type.trim().eq_ignore_ascii_case("antigravity") + && aether_ai_formats::normalize_api_format_alias(provider_api_format) + == "gemini:generate_content" +} + #[allow(clippy::too_many_arguments)] pub fn build_standard_request_body( body_json: &Value, @@ -132,9 +143,7 @@ pub fn build_standard_request_body_with_model_directives_and_request_headers_and .with_request_path(request_path) .with_upstream_stream(upstream_is_stream); format_context.preserve_gemini_tool_schemas = - provider_type.trim().eq_ignore_ascii_case("antigravity") - && aether_ai_formats::normalize_api_format_alias(provider_api_format) - == "gemini:generate_content"; + preserves_gemini_tool_schemas(provider_type, provider_api_format); if let Some(history_scope) = user_api_key_id { format_context = format_context.with_history_scope(history_scope); } diff --git a/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs b/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs index c0e0062a6..45c344161 100644 --- a/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs +++ b/crates/aether-ai/formats/src/formats/shared/standard_normalize.rs @@ -167,6 +167,40 @@ pub fn build_cross_format_openai_chat_request_body( ) } +/// Provider-aware entry point for gateway Chat planners. Keep private schema +/// conversion policy in the format crate while retaining legacy behavior elsewhere. +pub fn build_cross_format_openai_chat_request_body_with_provider_context( + body_json: &Value, + mapped_model: &str, + provider_type: &str, + provider_api_format: &str, + upstream_is_stream: bool, + enable_model_directives: bool, + history_scope: Option<&str>, +) -> Option { + if super::standard_matrix::preserves_gemini_tool_schemas(provider_type, provider_api_format) { + return super::standard_matrix::build_standard_request_body_with_model_directives( + body_json, + "openai:chat", + mapped_model, + provider_type, + provider_api_format, + "", + upstream_is_stream, + None, + history_scope, + enable_model_directives, + ); + } + build_cross_format_openai_chat_request_body_with_model_directives( + body_json, + mapped_model, + provider_api_format, + upstream_is_stream, + enable_model_directives, + ) +} + pub fn build_cross_format_openai_chat_request_body_with_model_directives( body_json: &Value, mapped_model: &str, @@ -342,6 +376,44 @@ pub fn build_cross_format_openai_responses_request_body_with_model_directives( ) } +/// Provider-aware Responses entry point; preserve history scoping and defer +/// private tool schema lowering without exposing provider policy to the gateway. +#[allow(clippy::too_many_arguments)] +pub fn build_cross_format_openai_responses_request_body_with_provider_context( + body_json: &Value, + mapped_model: &str, + client_api_format: &str, + provider_type: &str, + provider_api_format: &str, + upstream_is_stream: bool, + enable_model_directives: bool, + history_scope: Option<&str>, +) -> Option { + if super::standard_matrix::preserves_gemini_tool_schemas(provider_type, provider_api_format) { + return super::standard_matrix::build_standard_request_body_with_model_directives( + body_json, + client_api_format, + mapped_model, + provider_type, + provider_api_format, + "", + upstream_is_stream, + None, + history_scope, + enable_model_directives, + ); + } + build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope( + body_json, + mapped_model, + client_api_format, + provider_api_format, + upstream_is_stream, + enable_model_directives, + history_scope, + ) +} + pub fn build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope( body_json: &Value, mapped_model: &str, @@ -433,6 +505,145 @@ mod tests { }; use serde_json::{json, Value}; + #[test] + fn provider_context_builders_preserve_private_schemas_and_legacy_routes() { + use crate::api::{ + build_cross_format_openai_chat_request_body_with_provider_context as chat, + build_cross_format_openai_responses_request_body_with_provider_context as responses, + }; + let schema = json!({"type":"object", "properties":{"mode":{"const":"fast"}}}); + let chat_input = json!({"model":"client", "messages":[{"role":"user","content":"hi"}], + "tools":[{"type":"function","function":{"name":"probe","parameters":schema}}]}); + let responses_input = json!({"model":"client", "input":"hi", + "tools":[{"type":"function","name":"probe","parameters":schema}]}); + for provider in ["antigravity", " AnTiGrAvItY ", "gemini", "openai"] { + for target in [ + "gemini:generate_content", + "claude:messages", + "openai:responses", + ] { + for stream in [false, true] { + for directives in [false, true] { + for input in [&chat_input, &responses_input] { + let actual = chat( + input, + "claude-test", + provider, + target, + stream, + directives, + Some("seam-test"), + ); + let expected = + if super::super::standard_matrix::preserves_gemini_tool_schemas( + provider, target, + ) { + super::super::standard_matrix::build_standard_request_body_with_model_directives( + input, "openai:chat", "claude-test", provider, target, "", stream, None, Some("seam-test"), directives) + } else { + super::build_cross_format_openai_chat_request_body_with_model_directives( + input, "claude-test", target, stream, directives) + }; + assert!(actual.is_some(), "chat {provider} {target}"); + assert_eq!(actual, expected); + if target == "gemini:generate_content" { + assert_eq!( + actual.unwrap()["tools"][0]["functionDeclarations"][0] + ["parameters"] + == schema, + provider.trim().eq_ignore_ascii_case("antigravity") + ); + } + } + let actual = responses( + &responses_input, + "claude-test", + "openai:responses", + provider, + target, + stream, + directives, + Some("seam-test"), + ); + let expected = + if super::super::standard_matrix::preserves_gemini_tool_schemas( + provider, target, + ) { + super::super::standard_matrix::build_standard_request_body_with_model_directives( + &responses_input, "openai:responses", "claude-test", provider, target, "", stream, None, Some("seam-test"), directives) + } else { + super::build_cross_format_openai_responses_request_body_with_model_directives_and_history_scope( + &responses_input, "claude-test", "openai:responses", target, stream, directives, Some("seam-test")) + }; + // Same-format Responses uses the local builder, not this cross-format API. + assert_eq!( + actual.is_some(), + target != "openai:responses", + "responses {provider} {target}" + ); + assert_eq!(actual, expected); + } + } + } + } + } + + #[test] + fn provider_context_builders_keep_scoped_responses_history() { + use crate::api::{ + build_cross_format_openai_chat_request_body_with_provider_context as chat, + build_cross_format_openai_responses_request_body_with_provider_context as responses, + record_converted_response_history, + }; + let response_id = "resp_provider_context_seam_history"; + let scope = "provider-context-seam-history"; + record_converted_response_history(&json!({ + "needs_conversion":true, "client_api_format":"openai:responses", + "provider_api_format":"openai:chat", "api_key_id":scope, + "original_request_body":{"model":"client", "input":"first"} + }), &json!({"id":response_id, "status":"completed", "output":[{ + "type":"message", "role":"assistant", "content":[{"type":"output_text", "text":"remembered"}] + }]})).expect("seed scoped history"); + let input = json!({"model":"client", "previous_response_id":response_id, "input":"second"}); + for use_chat in [false, true] { + let build = |history_scope| { + if use_chat { + chat( + &input, + "claude-test", + "antigravity", + "gemini:generate_content", + true, + false, + history_scope, + ) + } else { + responses( + &input, + "claude-test", + "openai:responses", + "antigravity", + "gemini:generate_content", + true, + false, + history_scope, + ) + } + }; + if use_chat { + // The legacy Chat alternate-shape path does not hydrate scoped + // Responses history. Preserve that behavior during this refactor. + assert!(build(Some(scope)).is_none()); + continue; + } + let output = build(Some(scope)).expect("expand scoped history"); + assert_eq!(output["contents"][0]["parts"][0]["text"], "first"); + assert_eq!(output["contents"][1]["parts"][0]["text"], "remembered"); + assert_eq!(output["contents"][2]["parts"][0]["text"], "second"); + assert!(build(Some("different-seam-key")).is_none()); + } + } + fn object_keys(value: &Value) -> Vec<&str> { value .as_object() From 0486435f16b7db31add34133aa819e8477c81ea0 Mon Sep 17 00:00:00 2001 From: AAEE86 Date: Fri, 18 Sep 2026 14:24:25 +0800 Subject: [PATCH 22/72] =?UTF-8?q?feat(usage):=20=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E8=B7=B3=E8=BF=87=E5=80=99=E9=80=89=E5=8F=8A?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0=E5=B9=B6=E8=A1=A5=E9=BD=90=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=E7=AB=AF=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../observability/usage/summary_routes.rs | 187 +++++++++++++++++- frontend/src/api/usageRecords.ts | 8 + .../components/HorizontalRequestTimeline.vue | 111 ++++++++--- .../usage/components/UsageRecordsTable.vue | 90 ++++++--- .../HorizontalRequestTimeline.spec.ts | 50 +++++ .../__tests__/UsageRecordsTable.spec.ts | 34 ++++ .../usage/composables/useUsageFilters.ts | 3 + frontend/src/features/usage/types.ts | 3 +- .../usage/utils/__tests__/skipReason.spec.ts | 64 ++++++ .../usage/utils/__tests__/status.spec.ts | 14 ++ .../usage/utils/recordFilterPolicy.ts | 6 +- .../src/features/usage/utils/skipReason.ts | 98 +++++++++ frontend/src/features/usage/utils/status.ts | 10 + frontend/src/views/shared/Usage.vue | 2 + 14 files changed, 622 insertions(+), 58 deletions(-) create mode 100644 frontend/src/features/usage/utils/__tests__/skipReason.spec.ts create mode 100644 frontend/src/features/usage/utils/skipReason.ts diff --git a/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs b/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs index 14a3f0d95..06e5ca4e5 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs @@ -76,21 +76,30 @@ fn apply_admin_usage_status_filter(query: &mut UsageAuditListQuery, status: Opti "pending" | "streaming" | "completed" | "cancelled" => { query.statuses = Some(vec![status]); } - "has_fallback" | "has_retry" => {} + "has_fallback" | "has_retry" | "has_skipped_candidate" => {} _ => {} } } -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Debug, Default)] struct AdminUsageAttemptFlags { has_fallback: bool, has_retry: bool, + /// 是否存在"被调度跳过"的候选(调度阶段判定本次不可用,从未向上游发起请求)。 + /// + /// 这是与 has_fallback 正交的信号:has_fallback 表示"更靠前的候选真的失败并被换掉", + /// 而本字段表示"更靠前的候选压根没被发出去"。两者在日志列表里观感都是"换了提供商", + /// 但用户拿不到 has_fallback 小图标时容易误判为调度错误,故单独暴露。 + has_skipped_candidate: bool, + /// 跳过原因(去重、保持出现顺序),用于前端 tooltip 直接说明"为什么没用它"。 + skipped_candidate_reasons: Vec, } fn admin_usage_attempt_status_filter(status: Option<&str>) -> Option<&'static str> { match status?.trim().to_ascii_lowercase().as_str() { "has_fallback" => Some("has_fallback"), "has_retry" => Some("has_retry"), + "has_skipped_candidate" => Some("has_skipped_candidate"), _ => None, } } @@ -145,25 +154,52 @@ fn admin_usage_attempt_flags_from_candidates( }) }); let has_retry = candidates.iter().any(admin_usage_candidate_was_retried); + let skipped_candidate_reasons = admin_usage_skipped_candidate_reasons(candidates); AdminUsageAttemptFlags { has_fallback, has_retry, + has_skipped_candidate: !skipped_candidate_reasons.is_empty(), + skipped_candidate_reasons, } } +/// 收集被跳过候选的原因,去重并保持候选顺序(决定性的在前,便于阅读)。 +fn admin_usage_skipped_candidate_reasons(candidates: &[StoredRequestCandidate]) -> Vec { + let mut reasons = Vec::new(); + for candidate in candidates + .iter() + .filter(|candidate| candidate.status == RequestCandidateStatus::Skipped) + { + let Some(reason) = candidate + .skip_reason + .as_deref() + .map(str::trim) + .filter(|reason| !reason.is_empty()) + else { + continue; + }; + if !reasons.iter().any(|existing| existing == reason) { + reasons.push(reason.to_string()); + } + } + reasons +} + fn admin_usage_attempt_flags_for_item( item: &StoredRequestUsageAudit, flags_by_usage_id: &BTreeMap, request_candidate_reader_available: bool, ) -> AdminUsageAttemptFlags { - flags_by_usage_id.get(&item.id).copied().unwrap_or_else(|| { + flags_by_usage_id.get(&item.id).cloned().unwrap_or_else(|| { if request_candidate_reader_available { AdminUsageAttemptFlags::default() } else { AdminUsageAttemptFlags { has_fallback: admin_usage_has_fallback(item), has_retry: false, + has_skipped_candidate: false, + skipped_candidate_reasons: Vec::new(), } } }) @@ -432,6 +468,8 @@ fn admin_usage_matches_attempt_status( match status { "has_fallback" => flags.has_fallback, "has_retry" => flags.has_retry, + // 与 has_fallback 区分:这里是"更靠前的候选被调度跳过、根本没发出去" + "has_skipped_candidate" => flags.has_skipped_candidate, _ => true, } } @@ -503,6 +541,9 @@ fn build_admin_usage_records_response_with_attempt_flags( ); record["has_fallback"] = json!(flags.has_fallback); record["has_retry"] = json!(flags.has_retry); + // 被跳过的候选:前端据此提示"这次没用某个提供商,是因为它在调度阶段就被排除了"。 + record["has_skipped_candidate"] = json!(flags.has_skipped_candidate); + record["skipped_candidate_reasons"] = json!(flags.skipped_candidate_reasons); record }) .collect(); @@ -1031,9 +1072,11 @@ mod tests { use aether_data_contracts::repository::candidates::{ RequestCandidateStatus, StoredRequestCandidate, }; + use aether_data_contracts::repository::usage::StoredRequestUsageAudit; use serde_json::json; use super::{ + admin_usage_attempt_flags_from_candidates, admin_usage_skipped_candidate_reasons, admin_usage_terminal_candidate_state_override, build_admin_usage_keyword_search_query, build_admin_usage_records_query, latest_admin_usage_image_progress, AdminUsageSearchContext, @@ -1075,6 +1118,144 @@ mod tests { .expect("candidate should build") } + /// 构造一条"被调度跳过"的候选(从未向上游发起请求)。 + fn skipped_candidate(candidate_index: i32, reason: &str) -> StoredRequestCandidate { + let mut candidate = sample_candidate( + candidate_index, + RequestCandidateStatus::Skipped, + None, + None, + None, + ); + candidate.skip_reason = Some(reason.to_string()); + // 跳过候选没有开始时间,is_attempted 因此为 false + candidate.started_at_unix_ms = None; + candidate + } + + #[test] + fn skipped_candidate_reasons_are_deduplicated_in_candidate_order() { + let reasons = admin_usage_skipped_candidate_reasons(&[ + skipped_candidate(0, "key_rpm_exhausted"), + skipped_candidate(1, "provider_inactive"), + skipped_candidate(2, "key_rpm_exhausted"), + ]); + + assert_eq!( + reasons, + vec![ + "key_rpm_exhausted".to_string(), + "provider_inactive".to_string() + ] + ); + } + + #[test] + fn skipped_candidate_reasons_ignore_attempted_candidates() { + // 真正发起过请求的失败候选不属于"被跳过",避免与 has_fallback 语义混淆 + let failed = sample_candidate( + 0, + RequestCandidateStatus::Failed, + Some(503), + Some(1_000), + Some("upstream exploded"), + ); + assert!(admin_usage_skipped_candidate_reasons(&[failed]).is_empty()); + } + + #[test] + fn attempt_flags_report_skipped_candidates_without_fallback() { + let candidates = vec![ + skipped_candidate(0, "key_rpm_exhausted"), + sample_candidate( + 1, + RequestCandidateStatus::Success, + Some(200), + Some(900), + None, + ), + ]; + + let flags = admin_usage_attempt_flags_from_candidates(&sample_usage_audit(), &candidates); + + // 这正是用户遇到的场景:换了提供商,但没有任何候选失败过 + assert!(flags.has_skipped_candidate); + assert!(!flags.has_fallback); + assert_eq!( + flags.skipped_candidate_reasons, + vec!["key_rpm_exhausted".to_string()] + ); + } + + #[test] + fn attempt_flags_keep_fallback_and_skipped_candidate_independent() { + let candidates = vec![ + skipped_candidate(0, "provider_inactive"), + sample_candidate( + 1, + RequestCandidateStatus::Failed, + Some(503), + Some(500), + None, + ), + sample_candidate( + 2, + RequestCandidateStatus::Success, + Some(200), + Some(700), + None, + ), + ]; + + let flags = admin_usage_attempt_flags_from_candidates(&sample_usage_audit(), &candidates); + + assert!(flags.has_skipped_candidate); + assert!(flags.has_fallback); + } + + /// 最小可用的用量审计行,仅用于驱动 flags 计算(其中候选 id 为空即可)。 + fn sample_usage_audit() -> StoredRequestUsageAudit { + StoredRequestUsageAudit::new( + "usage-1".to_string(), + "req-1".to_string(), + Some("user-1".to_string()), + Some("api-key-1".to_string()), + Some("alice".to_string()), + Some("default".to_string()), + "OpenAI".to_string(), + "gpt-4.1".to_string(), + None, + None, + None, + None, + None, + Some("openai:chat".to_string()), + Some("openai".to_string()), + Some("chat".to_string()), + Some("openai:chat".to_string()), + Some("openai".to_string()), + Some("chat".to_string()), + false, + false, + 10, + 20, + 30, + 0.0, + 0.0, + Some(200), + None, + None, + None, + None, + "completed".to_string(), + "settled".to_string(), + 1_000, + 1_001, + None, + ) + .expect("usage should build") + } + #[test] fn admin_usage_active_override_uses_current_terminal_candidate_latency() { let candidate = sample_candidate( diff --git a/frontend/src/api/usageRecords.ts b/frontend/src/api/usageRecords.ts index 8ef0b4150..c17f382d2 100644 --- a/frontend/src/api/usageRecords.ts +++ b/frontend/src/api/usageRecords.ts @@ -65,5 +65,13 @@ export interface UsageRecord { response_time_updated_at?: string | null has_fallback?: boolean has_retry?: boolean + /** + * 是否存在被调度跳过的候选(候选在调度阶段即被判定不可用,从未向上游发起请求)。 + * 与 has_fallback 的区别:has_fallback 代表"更靠前的候选真的失败了", + * 本字段代表"更靠前的候选压根没被发出去",用于解释"无报错却换了提供商"。 + */ + has_skipped_candidate?: boolean + /** 被跳过候选的原因列表(后端已按候选顺序去重) */ + skipped_candidate_reasons?: string[] image_progress?: ImageProgress | null } diff --git a/frontend/src/features/usage/components/HorizontalRequestTimeline.vue b/frontend/src/features/usage/components/HorizontalRequestTimeline.vue index ea86686e7..18f2b3c99 100644 --- a/frontend/src/features/usage/components/HorizontalRequestTimeline.vue +++ b/frontend/src/features/usage/components/HorizontalRequestTimeline.vue @@ -36,6 +36,21 @@ {{ getFinalStatusLabel(computedFinalStatus) }} + +
{{ formatLatency(totalTraceLatency) }} @@ -449,7 +464,7 @@ v-if="currentAttemptSkipReasonDisplay" class="skip-reason" > - 跳过原因 + {{ currentAttemptSkipReasonLabel }} {{ currentAttemptSkipReasonDisplay }} (() => { +const allTraceCandidates = computed(() => { if (!trace.value) return [] return [...trace.value.candidates] .filter(c => TIMELINE_STATUS.includes(c.status)) .sort(compareBySchedulingOrder) }) +/** + * 被跳过的候选:调度阶段就判定"这次不能用",从未真正向上游发起请求。 + * + * 默认展示:它是"为什么这次没用优先级更高的提供商"的直接答案, + * 隐藏后容易让人误以为该提供商从未参与调度。开关供长时间排查时收起。 + */ +const showSkippedCandidates = ref(true) + +const skippedTraceCandidates = computed( + () => allTraceCandidates.value.filter(c => c.status === 'skipped'), +) + +const rawTimeline = computed(() => { + if (showSkippedCandidates.value) return allTraceCandidates.value + return allTraceCandidates.value.filter(c => c.status !== 'skipped') +}) + const schedulingAudit = computed | null>(() => { const metadata = props.requestMetadata @@ -1233,7 +1266,12 @@ const latestTraceAttemptForState = computed(() => { const candidates = rawTimeline.value for (let index = candidates.length - 1; index >= 0; index -= 1) { const candidate = candidates[index] - if (candidate.status !== 'available' && candidate.status !== 'unused') { + // 跳过/未使用候选没有状态码与错误信息,不能代表本次请求的最终结果。 + if ( + candidate.status !== 'available' && + candidate.status !== 'unused' && + candidate.status !== 'skipped' + ) { return candidate } } @@ -1418,35 +1456,30 @@ const currentAttemptKeyFormatsDisplay = computed(() => { .map(format => formatApiFormat(format)) .join(' / ') }) -const SKIP_REASON_LABELS: Record = { - auth_api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限', - api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限', - pool_key_lease_busy: '池内账号正被其他请求占用', - provider_concurrency_limit_reached: '上游提供商并发已达上限', - provider_key_concurrency_limit_reached: '上游账号并发已达上限', - provider_request_body_build_failed: '上游请求体转换失败', - provider_request_body_missing: '无法构建上游请求体', -} const currentAttemptSkipReasonDisplay = computed(() => { const attempt = currentAttempt.value if (!attempt?.skip_reason) return '' - const skipReasonLabel = SKIP_REASON_LABELS[attempt.skip_reason] - if (skipReasonLabel) { - return skipReasonLabel + // transport_unsupported 这类"泛化原因"优先展示后端采集到的具体细节,便于排查。 + if (attempt.skip_reason === 'transport_unsupported') { + const transportDiagnostics = resolveTransportDiagnostics(attempt) + const requestPair = extractObject(transportDiagnostics?.request_pair) + const detailedReason = typeof requestPair?.transport_unsupported_reason === 'string' + ? requestPair.transport_unsupported_reason.trim() + : '' + return detailedReason || formatCandidateSkipReason(attempt.skip_reason) } - if (attempt.skip_reason !== 'transport_unsupported') { - return attempt.skip_reason - } + return formatCandidateSkipReason(attempt.skip_reason) +}) - const transportDiagnostics = resolveTransportDiagnostics(attempt) - const requestPair = extractObject(transportDiagnostics?.request_pair) - const detailedReason = typeof requestPair?.transport_unsupported_reason === 'string' - ? requestPair.transport_unsupported_reason.trim() - : '' - - return detailedReason || attempt.skip_reason +/** + * 区分"跳过"与"尝试失败":被跳过的候选从未发到上游, + * 不写清楚容易被误读成"上游返回了错误"。 + */ +const currentAttemptSkipReasonLabel = computed(() => { + const status = currentAttempt.value?.status + return status === 'skipped' ? '跳过原因(未向上游发起请求)' : '跳过原因' }) const currentAttemptFailureDiagnostic = computed<{ @@ -2212,7 +2245,9 @@ const loadTrace = async (silent = false) => { error.value = null try { - internalTrace.value = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: true }) + // 始终拉取全部候选:被跳过的候选是"为什么没用某个提供商"的关键证据, + // 是否显示由前端开关控制,避免再发一次请求。 + internalTrace.value = await requestTraceApi.getRequestTrace(requestId, { attemptedOnly: false }) } catch (err: unknown) { if (isAxiosError(err) && err.response?.status === 404) { internalTrace.value = null @@ -2798,6 +2833,30 @@ function getDisplayStatus(attempt: CandidateRecord | null | undefined): string { cursor: not-allowed; } +/* 「显示/隐藏被跳过候选」开关:低调的描边小按钮,不抢主状态徽标的视觉重心 */ +.skipped-toggle { + padding: 0.125rem 0.5rem; + border: 1px dashed hsl(var(--border)); + border-radius: 9999px; + background: transparent; + color: hsl(var(--muted-foreground)); + font-size: 0.75rem; + line-height: 1.5; + cursor: pointer; + transition: all 0.15s ease; +} + +.skipped-toggle:hover { + color: hsl(var(--foreground)); + border-color: hsl(var(--muted-foreground) / 0.5); +} + +.skipped-toggle.active { + border-style: solid; + border-color: hsl(var(--primary) / 0.5); + color: hsl(var(--primary)); +} + .nav-info { font-size: 0.8rem; font-weight: 500; diff --git a/frontend/src/features/usage/components/UsageRecordsTable.vue b/frontend/src/features/usage/components/UsageRecordsTable.vue index 63d0b3988..9bf624bf9 100644 --- a/frontend/src/features/usage/components/UsageRecordsTable.vue +++ b/frontend/src/features/usage/components/UsageRecordsTable.vue @@ -149,32 +149,14 @@ - - 全部类型 - - - HTTP 流式 - - - HTTP 标准 - - - WebSocket (WS) - - - 活跃 - - - 失败 - - - 已取消 - - - 发生重试 - - - 发生转移 + + + {{ option.label }} @@ -362,6 +344,28 @@ · {{ formatRecordProviderSegment(record) }} + + + +
@@ -791,6 +795,18 @@ title="此请求发生了 Provider 故障转移" aria-label="发生 Provider 故障转移" /> + + reason && all.indexOf(reason) === index) + + const header = '本次有候选在调度阶段被跳过,请求未发往该候选(因此不会有上游报错)' + if (!reasons.length) return header + return `${header}\n跳过原因:${reasons.join(';')}` +} + +function skippedCandidateAriaLabel(record: UsageRecord): string { + const reasons = (record.skipped_candidate_reasons ?? []).map(formatCandidateSkipReason) + return reasons.length ? `有候选被调度跳过:${reasons.join(';')}` : '有候选被调度跳过' +} + // 获取 API 格式的 tooltip(包含转换信息) function getApiFormatTooltip(record: UsageRecord): string { if (!record.api_format) { diff --git a/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts b/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts index 8a933cc78..2715b1af9 100644 --- a/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts +++ b/frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts @@ -425,6 +425,56 @@ describe('HorizontalRequestTimeline', () => { expect(nodeDots[2].classList.contains('status-pending')).toBe(true) }) + it('shows a skipped candidate with a Chinese reason and can collapse it', async () => { + const trace = buildTrace([ + buildCandidate({ + id: 'cand-skipped-rpm', + provider_id: 'provider-1', + provider_name: 'Provider 1', + key_id: 'key-1', + key_name: 'Key 1', + candidate_index: 0, + status: 'skipped', + skip_reason: 'key_rpm_exhausted', + started_at: undefined, + finished_at: undefined, + }), + buildCandidate({ + id: 'cand-success-2', + provider_id: 'provider-2', + provider_name: 'Provider 2', + key_id: 'key-2', + key_name: 'Key 2', + candidate_index: 1, + status: 'success', + }), + ]) + + const root = mountTimeline(trace) + await nextTick() + + // 被跳过的候选默认可见:它是"为什么没用这个提供商"的答案 + expect([...root.querySelectorAll('.node-label')] + .map(label => label.textContent?.trim())) + .toEqual(['Provider 1', 'Provider 2']) + + // 点击第一个节点查看详情,应看到中文跳过原因 + root.querySelector('.minimal-node-group')?.click() + await nextTick() + expect(root.textContent).toContain('密钥本分钟请求数已达上限') + // 必须点明"没有向上游发起请求",否则会被误读成上游报错 + expect(root.textContent).toContain('未向上游发起请求') + + // 开关可把跳过候选整体收起 + const toggle = root.querySelector('.skipped-toggle') + expect(toggle).not.toBeNull() + toggle?.click() + await nextTick() + expect([...root.querySelectorAll('.node-label')] + .map(label => label.textContent?.trim())) + .toEqual(['Provider 2']) + }) + it('keeps successful runtime pool key visible when only pool_key_index is recorded', async () => { const trace = buildTrace([ buildCandidate({ diff --git a/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts b/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts index 58a8d5d1e..b3dfe1896 100644 --- a/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts +++ b/frontend/src/features/usage/components/__tests__/UsageRecordsTable.spec.ts @@ -92,6 +92,7 @@ vi.mock('lucide-vue-next', async () => { EyeOff: Icon, Search: Icon, Shuffle: Icon, + Ban: Icon, ChevronDown: Icon, Check: Icon, } @@ -679,4 +680,37 @@ describe('UsageRecordsTable', () => { expect(root.querySelector('[data-usage-attempt-marker="fallback"]')).not.toBeNull() expect(root.querySelector('[data-usage-attempt-marker="retry"]')).not.toBeNull() }) + + it('shows the skipped-candidate marker when a candidate was skipped by scheduling', () => { + const root = mountUsageRecordsTable([buildRecord({ + has_skipped_candidate: true, + skipped_candidate_reasons: ['key_rpm_exhausted'], + })]) + + const marker = root.querySelector('[data-usage-attempt-marker="skipped-candidate"]') + expect(marker).not.toBeNull() + // tooltip 必须说明"请求未发往该候选",否则用户会以为上游报了错 + const title = marker?.getAttribute('title') ?? '' + expect(title).toContain('调度阶段被跳过') + expect(title).toContain('不会有上游报错') + expect(title).toContain('密钥本分钟请求数已达上限') + }) + + it('prefers the fallback marker over the skipped-candidate marker', () => { + // 真正发生过故障转移时,琥珀色转移图标信息量更大,不再叠加灰色角标 + const root = mountUsageRecordsTable([buildRecord({ + has_fallback: true, + has_skipped_candidate: true, + skipped_candidate_reasons: ['key_rpm_exhausted'], + })]) + + expect(root.querySelector('[data-usage-attempt-marker="fallback"]')).not.toBeNull() + expect(root.querySelector('[data-usage-attempt-marker="skipped-candidate"]')).toBeNull() + }) + + it('hides the skipped-candidate marker when no candidate was skipped', () => { + const root = mountUsageRecordsTable([buildRecord({ has_skipped_candidate: false })]) + + expect(root.querySelector('[data-usage-attempt-marker="skipped-candidate"]')).toBeNull() + }) }) diff --git a/frontend/src/features/usage/composables/useUsageFilters.ts b/frontend/src/features/usage/composables/useUsageFilters.ts index 465b8dba1..3e712d9e0 100644 --- a/frontend/src/features/usage/composables/useUsageFilters.ts +++ b/frontend/src/features/usage/composables/useUsageFilters.ts @@ -3,6 +3,7 @@ import type { UsageRecord, FilterStatusValue } from '../types' import { hasUsageFallback, hasUsageRetry, + hasUsageSkippedCandidate, isUsageRecordFailed, isUsageUpstreamStream, isUsageWebSocket, @@ -97,6 +98,8 @@ export function useUsageFilters(options: UseUsageFiltersOptions) { records = records.filter(record => hasUsageFallback(record)) } else if (filterStatus.value === 'has_retry') { records = records.filter(record => hasUsageRetry(record)) + } else if (filterStatus.value === 'has_skipped_candidate') { + records = records.filter(record => hasUsageSkippedCandidate(record)) } } diff --git a/frontend/src/features/usage/types.ts b/frontend/src/features/usage/types.ts index 668d14efb..cb6a12b70 100644 --- a/frontend/src/features/usage/types.ts +++ b/frontend/src/features/usage/types.ts @@ -100,7 +100,8 @@ export type FilterStatusValue = 'failed' | 'cancelled' | 'has_fallback' | - 'has_retry' + 'has_retry' | + 'has_skipped_candidate' // 默认统计状态 export function createDefaultStats(): UsageStatsState { diff --git a/frontend/src/features/usage/utils/__tests__/skipReason.spec.ts b/frontend/src/features/usage/utils/__tests__/skipReason.spec.ts new file mode 100644 index 000000000..d84c0a22a --- /dev/null +++ b/frontend/src/features/usage/utils/__tests__/skipReason.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' + +import { + CANDIDATE_SKIP_REASON_LABELS, + formatCandidateSkipReason, + isNonAttemptedCandidateStatus, + isSkippedCandidateStatus, +} from '../skipReason' + +describe('candidate skip reason formatting', () => { + it('translates known skip reasons into Chinese labels', () => { + expect(formatCandidateSkipReason('key_rpm_exhausted')).toBe('密钥本分钟请求数已达上限') + expect(formatCandidateSkipReason('provider_concurrency_limit_reached')).toBe('上游提供商并发已达上限') + expect(formatCandidateSkipReason('key_circuit_open')).toBe('密钥熔断中(连续失败后暂停)') + }) + + it('trims surrounding whitespace before lookup', () => { + expect(formatCandidateSkipReason(' key_rpm_exhausted ')).toBe('密钥本分钟请求数已达上限') + }) + + it('falls back to the raw reason so new backend reasons stay visible', () => { + // 后端新增白名单原因、前端还没补翻译时,不能丢信息。 + expect(formatCandidateSkipReason('brand_new_reason')).toBe('brand_new_reason') + }) + + it('returns an empty string for missing or blank reasons', () => { + expect(formatCandidateSkipReason(undefined)).toBe('') + expect(formatCandidateSkipReason(null)).toBe('') + expect(formatCandidateSkipReason(' ')).toBe('') + }) + + it('labels every backend allowlisted reason', () => { + // 与后端 REQUEST_CANDIDATE_SKIP_REASONS 白名单保持同步的关键集合抽查。 + const criticalReasons = [ + 'account_quota_exhausted', + 'api_key_concurrency_limit_reached', + 'auth_api_key_concurrency_limit_reached', + 'key_circuit_open', + 'key_health_score_zero', + 'key_rpm_exhausted', + 'pool_key_lease_busy', + 'provider_concurrency_limit_reached', + 'provider_key_concurrency_limit_reached', + 'provider_quota_blocked', + 'transport_unsupported', + ] + for (const reason of criticalReasons) { + expect(CANDIDATE_SKIP_REASON_LABELS[reason], `missing label for ${reason}`).toBeTruthy() + } + }) + + it('distinguishes skipped candidates from attempted ones', () => { + expect(isSkippedCandidateStatus('skipped')).toBe(true) + expect(isSkippedCandidateStatus('failed')).toBe(false) + expect(isSkippedCandidateStatus(undefined)).toBe(false) + + // available/unused 同样是"从未尝试",用于时间线是否展示的判定。 + expect(isNonAttemptedCandidateStatus('skipped')).toBe(true) + expect(isNonAttemptedCandidateStatus('available')).toBe(true) + expect(isNonAttemptedCandidateStatus('unused')).toBe(true) + expect(isNonAttemptedCandidateStatus('success')).toBe(false) + expect(isNonAttemptedCandidateStatus('failed')).toBe(false) + }) +}) diff --git a/frontend/src/features/usage/utils/__tests__/status.spec.ts b/frontend/src/features/usage/utils/__tests__/status.spec.ts index a94629b33..dbdbdadc5 100644 --- a/frontend/src/features/usage/utils/__tests__/status.spec.ts +++ b/frontend/src/features/usage/utils/__tests__/status.spec.ts @@ -4,6 +4,7 @@ import { formatUsageStreamLabel, hasUsageFallback, hasUsageRetry, + hasUsageSkippedCandidate, isUsageRecordFailed, isUsageRecordSuccessful, isUsageWebSocket, @@ -178,6 +179,19 @@ describe('usage status helpers', () => { expect(hasUsageRetry(buildUsageRecord({ has_retry: undefined }))).toBe(false) }) + it('uses explicit has_skipped_candidate flag for scheduling-skip filtering', () => { + expect(hasUsageSkippedCandidate(buildUsageRecord({ has_skipped_candidate: true }))).toBe(true) + expect(hasUsageSkippedCandidate(buildUsageRecord({ has_skipped_candidate: false }))).toBe(false) + expect(hasUsageSkippedCandidate(buildUsageRecord({ has_skipped_candidate: undefined }))).toBe(false) + }) + + it('keeps skipped-candidate signal independent from fallback signal', () => { + // 被调度跳过 ≠ 故障转移:前者请求从未发出,因此不应被 hasUsageFallback 认领 + const skippedOnly = buildUsageRecord({ has_skipped_candidate: true, has_fallback: false }) + expect(hasUsageSkippedCandidate(skippedOnly)).toBe(true) + expect(hasUsageFallback(skippedOnly)).toBe(false) + }) + it('recognizes persisted WebSocket usage records', () => { expect(isUsageWebSocket(buildUsageRecord({ is_websocket: true }))).toBe(true) expect(isUsageWebSocket(buildUsageRecord({ is_websocket: false }))).toBe(false) diff --git a/frontend/src/features/usage/utils/recordFilterPolicy.ts b/frontend/src/features/usage/utils/recordFilterPolicy.ts index dd2966fb3..3065a02dc 100644 --- a/frontend/src/features/usage/utils/recordFilterPolicy.ts +++ b/frontend/src/features/usage/utils/recordFilterPolicy.ts @@ -1,7 +1,11 @@ import type { FilterStatusValue } from '../types' export function isUserLocalOnlyRecordStatus(status: FilterStatusValue): boolean { - return status === 'has_retry' || status === 'has_fallback' + // 这三个标记都由后端在列表响应里直接给出,但用户侧接口不支持作为服务端筛选条件, + // 因此统一走前端本地过滤。 + return status === 'has_retry' + || status === 'has_fallback' + || status === 'has_skipped_candidate' } export function shouldUseServerUserRecordFilters(input: { diff --git a/frontend/src/features/usage/utils/skipReason.ts b/frontend/src/features/usage/utils/skipReason.ts new file mode 100644 index 000000000..cdfe2e8f0 --- /dev/null +++ b/frontend/src/features/usage/utils/skipReason.ts @@ -0,0 +1,98 @@ +/** + * 候选"被跳过"原因的中文标签。 + * + * 这些字符串来自后端 `StoredRequestCandidate.skip_reason`,取值受 + * `crates/aether-data/contracts/src/repository/candidates/types.rs` 里的 + * `REQUEST_CANDIDATE_SKIP_REASONS` 白名单约束(未知原因会被后端统一清洗成 + * `unclassified_candidate_skip_reason`)。 + * + * 维护约定:后端新增白名单项时,这里补一条中文说明;缺失时前端会原样显示英文, + * 不会丢信息,所以可以安全地"先上线原因、后补翻译"。 + */ +export const CANDIDATE_SKIP_REASON_LABELS: Record = { + // —— 调度期运行时可选择性(不满足条件,从未真正发起请求)—— + account_quota_exhausted: '账号额度已耗尽', + oauth_invalid: 'OAuth 凭证已失效', + provider_quota_blocked: '上游提供商额度已用尽', + provider_concurrency_limit_reached: '上游提供商并发已达上限', + provider_key_concurrency_limit_reached: '上游账号并发已达上限', + provider_inactive: '提供商已停用', + key_inactive: '密钥已停用', + key_circuit_open: '密钥熔断中(连续失败后暂停)', + key_health_score_zero: '密钥健康分为 0', + key_rpm_exhausted: '密钥本分钟请求数已达上限', + key_model_disabled: '该密钥已停用此模型', + key_model_not_allowed: '该密钥不允许使用此模型', + key_api_format_disabled: '该密钥已停用此 API 格式', + api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限', + auth_api_key_concurrency_limit_reached: '调用方 API Key 并发已达上限', + + // —— 传输与路由策略门(在真正发请求前就被拦下)—— + auth_channel_mismatch: '鉴权通道不匹配', + auth_snapshot_missing: '缺少该密钥的鉴权快照', + endpoint_api_format_changed: '端点 API 格式已变更', + endpoint_inactive: '端点已停用', + format_conversion_disabled: '该提供商未开启格式转换', + mapped_model_missing: '缺少映射后的上游模型', + routing_profile_disallowed_key: '路由策略未允许该密钥', + routing_profile_disallowed_provider: '路由策略未允许该提供商', + upstream_url_missing: '缺少上游地址', + gemini_file_mapping_mismatch: 'Gemini 文件映射不匹配', + provider_request_body_build_failed: '上游请求体转换失败', + provider_request_body_missing: '无法构建上游请求体', + + // —— transport_* 系列:该提供商/端点不支持当前这种转发方式 —— + transport_unsupported: '该传输方式不受支持', + transport_api_format_mismatch: 'API 格式与端点不匹配', + transport_api_format_unsupported: '不支持该 API 格式', + transport_auth_unavailable: '无法获取可用鉴权', + transport_body_rules_apply_failed: '请求体改写规则执行失败', + transport_body_rules_unsupported: '不支持请求体改写规则', + transport_body_rules_unsupported_for_binary_upload: '二进制上传不支持请求体改写规则', + transport_custom_path_unsupported: '不支持自定义路径', + transport_endpoint_kind_unsupported: '不支持该端点类型', + transport_header_rules_apply_failed: '请求头改写规则执行失败', + transport_header_rules_unsupported: '不支持请求头改写规则', + transport_oauth_resolution_unsupported: '不支持该 OAuth 解析方式', + transport_operation_unsupported: '不支持该操作类型', + transport_profile_unsupported: '不支持该传输配置', + transport_provider_type_unsupported: '不支持该提供商类型', + transport_proxy_or_profile_unsupported: '不支持代理或传输配置', + transport_proxy_unsupported: '不支持该代理', + transport_snapshot_missing: '缺少传输快照', + + // —— 号池(pool)相关 —— + pool_group_exhausted: '号池已无可用账号', + pool_account_blocked: '号池账号已被封禁', + pool_account_exhausted: '号池账号额度已耗尽', + pool_active_probe_sealed: '号池探测中,暂不分配', + pool_cooldown: '号池账号冷却中', + pool_cost_limit_reached: '号池费用已达上限', + pool_key_lease_busy: '池内账号正被其他请求占用', + pool_score_member_missing: '号池评分成员缺失', +} + +/** 后端无法归类时使用的占位原因。 */ +export const UNCLASSIFIED_CANDIDATE_SKIP_REASON = 'unclassified_candidate_skip_reason' + +/** + * 把候选跳过原因转成中文展示文案。 + * + * 命中已知标签时返回中文;否则原样返回后端字符串(便于排查新原因), + * 空值返回空字符串,调用方据此判断是否展示。 + */ +export function formatCandidateSkipReason(reason?: string | null): string { + const normalized = typeof reason === 'string' ? reason.trim() : '' + if (!normalized) return '' + return CANDIDATE_SKIP_REASON_LABELS[normalized] ?? normalized +} + +/** 是否为"根本没有向上游发起请求"的跳过状态。 */ +export function isSkippedCandidateStatus(status?: string | null): boolean { + return status === 'skipped' +} + +/** 是否为"被枚举出来但从未尝试"的候选状态(含跳过与未使用)。 */ +export function isNonAttemptedCandidateStatus(status?: string | null): boolean { + return status === 'skipped' || status === 'available' || status === 'unused' +} diff --git a/frontend/src/features/usage/utils/status.ts b/frontend/src/features/usage/utils/status.ts index e93f2b948..c1aefc465 100644 --- a/frontend/src/features/usage/utils/status.ts +++ b/frontend/src/features/usage/utils/status.ts @@ -49,6 +49,16 @@ export function hasUsageRetry( return record.has_retry === true } +/** + * 是否有候选在调度阶段被跳过(请求从未发往该候选)。 + * 与 hasUsageFallback 互补:后者要求"更靠前的候选确实尝试并失败"。 + */ +export function hasUsageSkippedCandidate( + record: Pick +): boolean { + return record.has_skipped_candidate === true +} + export function isUsageWebSocket( record: Pick ): boolean { diff --git a/frontend/src/views/shared/Usage.vue b/frontend/src/views/shared/Usage.vue index 6c58617f2..fb7886b6e 100644 --- a/frontend/src/views/shared/Usage.vue +++ b/frontend/src/views/shared/Usage.vue @@ -441,6 +441,8 @@ const filteredRecords = computed(() => { records = records.filter(record => hasUsageFallback(record)) } else if (filterStatus.value === 'has_retry') { records = records.filter(record => record.has_retry === true) + } else if (filterStatus.value === 'has_skipped_candidate') { + records = records.filter(record => record.has_skipped_candidate === true) } } From a95f0d2488a211b04cf0b8e724d9b7363aab2124 Mon Sep 17 00:00:00 2001 From: wangpengxiang Date: Fri, 18 Sep 2026 16:03:46 +0800 Subject: [PATCH 23/72] feat(stats): add user group usage views --- .../route/admin/observability_families.rs | 14 + .../src/control/tests/admin_stats.rs | 22 + .../src/handlers/admin/observability/mod.rs | 28 + .../observability/monitoring/activity.rs | 1 + .../observability/stats/analytics_routes.rs | 13 + .../admin/observability/stats/cost_routes.rs | 1 + .../admin/observability/stats/leaderboard.rs | 13 +- .../observability/stats/leaderboard_routes.rs | 167 +++++- .../observability/usage/summary_routes.rs | 6 + .../src/handlers/admin/request/users.rs | 24 + .../src/tests/control/admin/stats.rs | 132 ++++- .../aether-admin/src/observability/stats.rs | 47 ++ .../adapters/postgres/src/usage/mod.rs | 249 ++++---- .../adapters/postgres/src/usage/tests.rs | 6 +- .../contracts/src/repository/usage/types.rs | 9 + .../runtime/src/lifecycle/migrate/tests.rs | 1 + .../runtime/src/repository/usage/memory.rs | 27 + .../src/repository/usage/memory/tests.rs | 61 +- .../__tests__/admin-analytics-cache.spec.ts | 31 + frontend/src/api/admin.ts | 34 ++ frontend/src/api/usage.ts | 1 + .../src/components/stats/LeaderboardTable.vue | 51 +- frontend/src/i18n/messages.ts | 76 +++ frontend/src/views/admin/UserStats.vue | 530 +++++++++++------- 24 files changed, 1198 insertions(+), 346 deletions(-) diff --git a/apps/aether-gateway/src/control/route/admin/observability_families.rs b/apps/aether-gateway/src/control/route/admin/observability_families.rs index 54eea3921..7a1baa2ef 100644 --- a/apps/aether-gateway/src/control/route/admin/observability_families.rs +++ b/apps/aether-gateway/src/control/route/admin/observability_families.rs @@ -596,6 +596,20 @@ pub(super) fn classify_admin_observability_family_route( "admin:stats", false, )) + } else if method == http::Method::GET + && matches!( + normalized_path, + "/api/admin/stats/leaderboard/user-groups" + | "/api/admin/stats/leaderboard/user-groups/" + ) + { + Some(classified( + "admin_proxy", + "stats_manage", + "leaderboard_user_groups", + "admin:stats", + false, + )) } else if method == http::Method::GET && matches!( normalized_path, diff --git a/apps/aether-gateway/src/control/tests/admin_stats.rs b/apps/aether-gateway/src/control/tests/admin_stats.rs index 63c78530c..04cb7b94c 100644 --- a/apps/aether-gateway/src/control/tests/admin_stats.rs +++ b/apps/aether-gateway/src/control/tests/admin_stats.rs @@ -157,6 +157,28 @@ fn classifies_admin_stats_leaderboard_models_as_admin_proxy_route() { assert!(!decision.is_execution_runtime_candidate()); } +#[test] +fn classifies_admin_stats_leaderboard_user_groups_as_admin_proxy_route() { + let headers = headers(&[]); + let uri: Uri = "/api/admin/stats/leaderboard/user-groups" + .parse() + .expect("uri should parse"); + let decision = + classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify"); + + assert_eq!(decision.route_class.as_deref(), Some("admin_proxy")); + assert_eq!(decision.route_family.as_deref(), Some("stats_manage")); + assert_eq!( + decision.route_kind.as_deref(), + Some("leaderboard_user_groups") + ); + assert_eq!( + decision.auth_endpoint_signature.as_deref(), + Some("admin:stats") + ); + assert!(!decision.is_execution_runtime_candidate()); +} + #[test] fn classifies_admin_stats_leaderboard_users_as_admin_proxy_route() { let headers = headers(&[]); diff --git a/apps/aether-gateway/src/handlers/admin/observability/mod.rs b/apps/aether-gateway/src/handlers/admin/observability/mod.rs index 27904b9ff..5ca53be1b 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/mod.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/mod.rs @@ -11,3 +11,31 @@ pub(crate) use self::stats::{ }; pub(crate) use self::stats::{AdminStatsTimeRange, AdminStatsUsageFilter}; pub(crate) use self::usage::maybe_build_local_admin_usage_response; + +pub(crate) async fn resolve_usage_user_group_scope( + state: &crate::handlers::admin::request::AdminAppState<'_>, + query: Option<&str>, + include_inactive: bool, + exclude_admin: bool, +) -> Result>, String>, crate::GatewayError> { + let group_id = crate::handlers::admin::shared::query_param_value(query, "user_group_id"); + let Some(group_id) = group_id else { + return Ok(Ok(None)); + }; + if crate::handlers::admin::shared::query_param_value(query, "user_id").is_some() { + return Ok(Err( + "user_id and user_group_id cannot be used together".to_string() + )); + } + if !state.has_user_data_reader() { + return Ok(Err("user group data is unavailable".to_string())); + } + + match state + .resolve_usage_user_group_member_ids(&group_id, include_inactive, exclude_admin) + .await? + { + Some(user_ids) => Ok(Ok(Some(user_ids))), + None => Ok(Err("user_group_id does not exist".to_string())), + } +} diff --git a/apps/aether-gateway/src/handlers/admin/observability/monitoring/activity.rs b/apps/aether-gateway/src/handlers/admin/observability/monitoring/activity.rs index 14ed524dd..a7504cb03 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/monitoring/activity.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/monitoring/activity.rs @@ -172,6 +172,7 @@ pub(super) async fn build_admin_monitoring_system_status_response( created_from_unix_secs: today_start.timestamp().max(0) as u64, created_until_unix_secs: now_unix_secs.saturating_add(1), user_id: None, + user_ids: None, provider_name: None, model: None, }) diff --git a/apps/aether-gateway/src/handlers/admin/observability/stats/analytics_routes.rs b/apps/aether-gateway/src/handlers/admin/observability/stats/analytics_routes.rs index 45b59a005..7f0af1fe5 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/stats/analytics_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/stats/analytics_routes.rs @@ -1,3 +1,4 @@ +use super::super::resolve_usage_user_group_scope; use super::range::{build_comparison_range, parse_bounded_u32}; use super::resolve_admin_usage_time_range; use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; @@ -294,6 +295,17 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response( } let filters = AdminStatsUsageFilter::from_query(request_context.query_string()); + let user_ids = match resolve_usage_user_group_scope( + state, + request_context.query_string(), + false, + false, + ) + .await? + { + Ok(value) => value, + Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))), + }; let query_granularity = match granularity { AdminStatsGranularity::Hour => UsageTimeSeriesGranularity::Hour, AdminStatsGranularity::Day @@ -311,6 +323,7 @@ pub(super) async fn maybe_build_local_admin_stats_analytics_response( granularity: query_granularity, tz_offset_minutes: time_range.tz_offset_minutes, user_id: filters.user_id, + user_ids, provider_name: filters.provider_name, model: filters.model, }) diff --git a/apps/aether-gateway/src/handlers/admin/observability/stats/cost_routes.rs b/apps/aether-gateway/src/handlers/admin/observability/stats/cost_routes.rs index f4c85089b..6e6c2f34a 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/stats/cost_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/stats/cost_routes.rs @@ -77,6 +77,7 @@ pub(super) async fn maybe_build_local_admin_stats_cost_response( granularity: UsageTimeSeriesGranularity::Day, tz_offset_minutes: time_range.tz_offset_minutes, user_id: None, + user_ids: None, provider_name: None, model: None, }) diff --git a/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard.rs b/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard.rs index 6ad30f30e..6b7b28089 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard.rs @@ -3,12 +3,13 @@ use crate::GatewayError; use aether_data_contracts::repository::usage::StoredRequestUsageAudit; pub(super) use aether_admin::observability::stats::{ - build_admin_stats_leaderboard_response, build_api_key_leaderboard_items, - build_api_key_leaderboard_items_from_summaries, build_model_leaderboard_items, - build_model_leaderboard_items_from_summaries, build_user_leaderboard_items, - build_user_leaderboard_items_from_summaries, compare_leaderboard_items, compute_dense_rank, - AdminStatsLeaderboardItem, AdminStatsLeaderboardMetric, AdminStatsLeaderboardNameMode, - AdminStatsSortOrder, AdminStatsUserMetadata, + build_admin_stats_leaderboard_response, build_admin_stats_user_group_leaderboard_response, + build_api_key_leaderboard_items, build_api_key_leaderboard_items_from_summaries, + build_model_leaderboard_items, build_model_leaderboard_items_from_summaries, + build_user_leaderboard_items, build_user_leaderboard_items_from_summaries, + compare_leaderboard_items, compute_dense_rank, AdminStatsLeaderboardItem, + AdminStatsLeaderboardMetric, AdminStatsLeaderboardNameMode, AdminStatsSortOrder, + AdminStatsUserMetadata, }; pub(super) async fn load_user_leaderboard_metadata( diff --git a/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard_routes.rs b/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard_routes.rs index 41e5a3b9a..85b2cf4d3 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/stats/leaderboard_routes.rs @@ -1,7 +1,9 @@ +use super::super::resolve_usage_user_group_scope; use super::leaderboard::{ - build_admin_stats_leaderboard_response, build_api_key_leaderboard_items_from_summaries, - build_model_leaderboard_items_from_summaries, build_user_leaderboard_items_from_summaries, - compare_leaderboard_items, load_user_leaderboard_metadata, AdminStatsLeaderboardNameMode, + build_admin_stats_leaderboard_response, build_admin_stats_user_group_leaderboard_response, + build_api_key_leaderboard_items_from_summaries, build_model_leaderboard_items_from_summaries, + build_user_leaderboard_items_from_summaries, compare_leaderboard_items, + load_user_leaderboard_metadata, AdminStatsLeaderboardItem, AdminStatsLeaderboardNameMode, }; use super::range::{parse_bounded_u32, parse_nonnegative_usize}; use super::resolve_admin_usage_time_range; @@ -14,6 +16,7 @@ use aether_admin::observability::stats::{ }; use aether_data_contracts::repository::usage::{UsageLeaderboardGroupBy, UsageLeaderboardQuery}; use axum::{body::Body, http, response::Response}; +use std::collections::{BTreeMap, BTreeSet}; pub(super) async fn maybe_build_local_admin_stats_leaderboard_response( state: &AdminAppState<'_>, @@ -79,6 +82,7 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response( created_until_unix_secs, group_by: UsageLeaderboardGroupBy::Model, user_id: filters.user_id, + user_ids: None, provider_name: filters.provider_name, model: filters.model, }) @@ -156,6 +160,7 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response( created_until_unix_secs, group_by: UsageLeaderboardGroupBy::ApiKey, user_id: filters.user_id, + user_ids: None, provider_name: filters.provider_name, model: filters.model, }) @@ -206,6 +211,154 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response( ))); } + if request_context + .decision() + .and_then(|decision| decision.route_kind.as_deref()) + == Some("leaderboard_user_groups") + && request_context.method() == http::Method::GET + && matches!( + request_context.path(), + "/api/admin/stats/leaderboard/user-groups" + | "/api/admin/stats/leaderboard/user-groups/" + ) + { + let time_range = match resolve_admin_usage_time_range(query) { + Ok(value) => value, + Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))), + }; + let metric = match AdminStatsLeaderboardMetric::parse(query) { + Ok(value) => value, + Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))), + }; + let order = match AdminStatsSortOrder::parse(query) { + Ok(value) => value, + Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))), + }; + let limit = match query_param_value(query, "limit") + .map(|value| parse_bounded_u32("limit", &value, 1, 100)) + .transpose() + { + Ok(Some(value)) => value as usize, + Ok(None) => 10, + Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))), + }; + let offset = match query_param_value(query, "offset") + .map(|value| parse_nonnegative_usize("offset", &value)) + .transpose() + { + Ok(Some(value)) => value, + Ok(None) => 0, + Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))), + }; + let empty_counts = BTreeMap::new(); + if !state.has_usage_data_reader() || !state.has_user_data_reader() { + return Ok(Some(build_admin_stats_user_group_leaderboard_response( + metric, + Some(&time_range), + &[], + &empty_counts, + &empty_counts, + offset, + limit, + ))); + } + let include_inactive = query_param_bool(query, "include_inactive", false); + let exclude_admin = query_param_bool(query, "exclude_admin", false); + let filters = AdminStatsUsageFilter::from_query(query); + if filters.user_id.is_some() { + return Ok(Some(admin_stats_bad_request_response( + "user_id is not supported for the user group leaderboard".to_string(), + ))); + } + let Some((created_from_unix_secs, created_until_unix_secs)) = time_range.to_unix_bounds() + else { + return Ok(Some(build_admin_stats_user_group_leaderboard_response( + metric, + Some(&time_range), + &[], + &empty_counts, + &empty_counts, + offset, + limit, + ))); + }; + + let summaries = state + .summarize_usage_leaderboard(&UsageLeaderboardQuery { + created_from_unix_secs, + created_until_unix_secs, + group_by: UsageLeaderboardGroupBy::User, + user_id: None, + user_ids: None, + provider_name: filters.provider_name, + model: filters.model, + }) + .await?; + let user_ids = summaries + .iter() + .map(|item| item.group_key.clone()) + .collect::>(); + let user_metadata = load_user_leaderboard_metadata(state, &user_ids).await?; + let user_usage = build_user_leaderboard_items_from_summaries( + &summaries, + &user_metadata, + state.has_auth_user_data_reader(), + state.has_user_data_reader(), + include_inactive, + exclude_admin, + ) + .into_iter() + .map(|item| (item.id.clone(), item)) + .collect::>(); + + let mut leaderboard = Vec::new(); + let mut member_counts = BTreeMap::new(); + let mut active_member_counts = BTreeMap::new(); + for group in state.list_user_groups().await? { + let members = state.list_user_group_members(&group.id).await?; + let member_count = members.iter().filter(|member| !member.is_deleted).count(); + let active_member_count = members + .iter() + .filter(|member| !member.is_deleted && member.is_active) + .count(); + let scoped_user_ids = members + .iter() + .filter(|member| !member.is_deleted) + .filter(|member| include_inactive || member.is_active) + .filter(|member| !exclude_admin || !member.role.eq_ignore_ascii_case("admin")) + .map(|member| member.user_id.as_str()) + .collect::>(); + let mut item = AdminStatsLeaderboardItem { + id: group.id.clone(), + name: group.name, + requests: 0, + tokens: 0, + cost: 0.0, + }; + for user_id in scoped_user_ids { + if let Some(user) = user_usage.get(user_id) { + item.requests = item.requests.saturating_add(user.requests); + item.tokens = item.tokens.saturating_add(user.tokens); + item.cost += user.cost; + } + } + member_counts.insert(group.id.clone(), member_count); + active_member_counts.insert(group.id, active_member_count); + leaderboard.push(item); + } + leaderboard.sort_by(|left, right| compare_leaderboard_items(metric, order, left, right)); + + return Ok(Some(build_admin_stats_user_group_leaderboard_response( + metric, + Some(&time_range), + &leaderboard, + &member_counts, + &active_member_counts, + offset, + limit, + ))); + } + if request_context .decision() .and_then(|decision| decision.route_kind.as_deref()) @@ -253,6 +406,13 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response( let include_inactive = query_param_bool(query, "include_inactive", false); let exclude_admin = query_param_bool(query, "exclude_admin", false); let filters = AdminStatsUsageFilter::from_query(query); + let scoped_user_ids = + match resolve_usage_user_group_scope(state, query, include_inactive, exclude_admin) + .await? + { + Ok(value) => value, + Err(detail) => return Ok(Some(admin_stats_bad_request_response(detail))), + }; let Some((created_from_unix_secs, created_until_unix_secs)) = time_range.to_unix_bounds() else { return Ok(Some(admin_stats_leaderboard_empty_response( @@ -266,6 +426,7 @@ pub(super) async fn maybe_build_local_admin_stats_leaderboard_response( created_until_unix_secs, group_by: UsageLeaderboardGroupBy::User, user_id: filters.user_id, + user_ids: scoped_user_ids, provider_name: filters.provider_name, model: filters.model, }) diff --git a/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs b/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs index 14a3f0d95..e7541e523 100644 --- a/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs +++ b/apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs @@ -1,3 +1,4 @@ +use super::super::resolve_usage_user_group_scope; use super::super::stats::resolve_admin_usage_time_range; use super::analytics::admin_usage_api_key_names; use super::analytics::admin_usage_provider_key_names; @@ -710,11 +711,16 @@ pub(super) async fn maybe_build_local_admin_usage_summary_response( &Default::default(), ))); }; + let user_ids = match resolve_usage_user_group_scope(state, query, false, false).await? { + Ok(value) => value, + Err(detail) => return Ok(Some(admin_usage_bad_request_response(detail))), + }; let summary = state .summarize_usage_audits(&UsageAuditSummaryQuery { created_from_unix_secs, created_until_unix_secs, user_id: query_param_value(query, "user_id"), + user_ids, provider_name: query_param_value(query, "provider"), model: query_param_value(query, "model"), }) diff --git a/apps/aether-gateway/src/handlers/admin/request/users.rs b/apps/aether-gateway/src/handlers/admin/request/users.rs index 39d8554a3..d0b163be6 100644 --- a/apps/aether-gateway/src/handlers/admin/request/users.rs +++ b/apps/aether-gateway/src/handlers/admin/request/users.rs @@ -126,6 +126,30 @@ impl<'a> AdminAppState<'a> { self.app.list_user_group_members(group_id).await } + pub(crate) async fn resolve_usage_user_group_member_ids( + &self, + group_id: &str, + include_inactive: bool, + exclude_admin: bool, + ) -> Result>, GatewayError> { + if self.find_user_group_by_id(group_id).await?.is_none() { + return Ok(None); + } + + let mut user_ids = self + .list_user_group_members(group_id) + .await? + .into_iter() + .filter(|member| !member.is_deleted) + .filter(|member| include_inactive || member.is_active) + .filter(|member| !exclude_admin || !member.role.eq_ignore_ascii_case("admin")) + .map(|member| member.user_id) + .collect::>(); + user_ids.sort(); + user_ids.dedup(); + Ok(Some(user_ids)) + } + pub(crate) async fn replace_user_group_members( &self, group_id: &str, diff --git a/apps/aether-gateway/src/tests/control/admin/stats.rs b/apps/aether-gateway/src/tests/control/admin/stats.rs index 54d82d5e3..32fdb1e99 100644 --- a/apps/aether-gateway/src/tests/control/admin/stats.rs +++ b/apps/aether-gateway/src/tests/control/admin/stats.rs @@ -9,7 +9,8 @@ use aether_data::repository::auth::{ use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository; use aether_data::repository::usage::InMemoryUsageReadRepository; use aether_data::repository::users::{ - InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserSummary, + InMemoryUserReadRepository, StoredUserAuthRecord, StoredUserSummary, UpsertUserGroupRecord, + UserReadRepository, }; use aether_data_contracts::repository::usage::StoredRequestUsageAudit; use async_trait::async_trait; @@ -1937,6 +1938,135 @@ async fn gateway_handles_admin_stats_leaderboard_users_without_legacy_username_f upstream_handle.abort(); } +#[tokio::test] +async fn gateway_aggregates_admin_stats_by_current_user_group_membership() { + let (_upstream_url, upstream_hits, upstream_handle) = + start_stats_upstream("/api/admin/stats/leaderboard/user-groups").await; + let usage_repository = Arc::new(InMemoryUsageReadRepository::seed(vec![ + sample_usage_row( + "usage-group-a", + "req-group-a", + Some("user-1"), + Some("key-1"), + Some("key-1"), + "OpenAI", + "gpt-5", + 60, + 20, + 0.4, + 0.4, + DAY_1_UNIX_SECS, + ), + sample_usage_row( + "usage-group-b", + "req-group-b", + Some("user-2"), + Some("key-2"), + Some("key-2"), + "OpenAI", + "gpt-5", + 40, + 10, + 0.35, + 0.35, + DAY_1_UNIX_SECS + 10, + ), + sample_usage_row( + "usage-group-outside", + "req-group-outside", + Some("user-3"), + Some("key-3"), + Some("key-3"), + "OpenAI", + "gpt-5", + 100, + 50, + 1.5, + 1.5, + DAY_1_UNIX_SECS + 20, + ), + ])); + let user_repository = InMemoryUserReadRepository::seed_auth_users([ + sample_auth_user("user-1", "alice", "user", true), + sample_auth_user("user-2", "bob", "user", true), + sample_auth_user("user-3", "carol", "user", true), + ]); + let group = user_repository + .create_user_group(UpsertUserGroupRecord { + name: "Engineering".to_string(), + description: None, + priority: 0, + allowed_providers: None, + allowed_providers_mode: "inherit".to_string(), + allowed_api_formats: None, + allowed_api_formats_mode: "inherit".to_string(), + allowed_models: None, + allowed_models_mode: "inherit".to_string(), + rate_limit: None, + rate_limit_mode: "inherit".to_string(), + }) + .await + .expect("group creation should succeed") + .expect("group should be created"); + user_repository + .replace_user_group_members(&group.id, &["user-1".to_string(), "user-2".to_string()]) + .await + .expect("group members should be replaced"); + let data_state = GatewayDataState::with_usage_reader_for_tests(usage_repository) + .with_user_reader(Arc::new(user_repository)); + let gateway = build_router_with_state( + AppState::new() + .expect("gateway should build") + .with_data_state_for_tests(data_state), + ); + let (gateway_url, gateway_handle) = start_server(gateway).await; + let client = reqwest::Client::new(); + + let response = admin_request(client.get(format!( + "{gateway_url}/api/admin/stats/leaderboard/user-groups?start_date=2024-03-21&end_date=2024-03-21&metric=cost&tz_offset_minutes=0" + ))) + .send() + .await + .expect("group leaderboard request should succeed"); + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = response.json().await.expect("json body should parse"); + assert_eq!(payload["attribution"], "current_membership"); + assert_eq!(payload["total"], 1); + assert_eq!(payload["items"][0]["id"], group.id); + assert_eq!(payload["items"][0]["name"], "Engineering"); + assert_eq!(payload["items"][0]["requests"], 2); + assert_eq!(payload["items"][0]["cost"], 0.75); + assert_eq!(payload["items"][0]["member_count"], 2); + assert_eq!(payload["items"][0]["active_member_count"], 2); + + let response = admin_request(client.get(format!( + "{gateway_url}/api/admin/stats/leaderboard/users?start_date=2024-03-21&end_date=2024-03-21&metric=cost&tz_offset_minutes=0&user_group_id={}", + group.id + ))) + .send() + .await + .expect("group member leaderboard request should succeed"); + assert_eq!(response.status(), StatusCode::OK); + let payload: serde_json::Value = response.json().await.expect("json body should parse"); + assert_eq!(payload["total"], 2); + assert!(payload["items"] + .as_array() + .is_some_and(|items| { items.iter().all(|item| item["id"] != "user-3") })); + + let response = admin_request(client.get(format!( + "{gateway_url}/api/admin/stats/leaderboard/users?start_date=2024-03-21&end_date=2024-03-21&user_id=user-1&user_group_id={}", + group.id + ))) + .send() + .await + .expect("conflicting scope request should complete"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); + + gateway_handle.abort(); + upstream_handle.abort(); +} + #[tokio::test] async fn gateway_handles_admin_stats_leaderboard_users_locally_without_usage_reader() { let (upstream_url, upstream_hits, upstream_handle) = diff --git a/crates/aether-admin/src/observability/stats.rs b/crates/aether-admin/src/observability/stats.rs index a041b6524..cc2ff7630 100644 --- a/crates/aether-admin/src/observability/stats.rs +++ b/crates/aether-admin/src/observability/stats.rs @@ -841,6 +841,53 @@ pub fn build_admin_stats_leaderboard_response( .into_response() } +pub fn build_admin_stats_user_group_leaderboard_response( + metric: AdminStatsLeaderboardMetric, + time_range: Option<&AdminStatsTimeRange>, + leaderboard: &[AdminStatsLeaderboardItem], + member_counts: &std::collections::BTreeMap, + active_member_counts: &std::collections::BTreeMap, + offset: usize, + limit: usize, +) -> Response { + let total = leaderboard.len(); + let items: Vec<_> = leaderboard + .iter() + .enumerate() + .skip(offset) + .take(limit) + .map(|(index, item)| { + let rank = compute_dense_rank(metric, leaderboard, index); + let value = match metric { + AdminStatsLeaderboardMetric::Requests => json!(item.requests), + AdminStatsLeaderboardMetric::Tokens => json!(item.tokens), + AdminStatsLeaderboardMetric::Cost => json!(round_to(item.cost, 6)), + }; + json!({ + "rank": rank, + "id": item.id, + "name": item.name, + "value": value, + "requests": item.requests, + "tokens": item.tokens, + "cost": round_to(item.cost, 6), + "member_count": member_counts.get(&item.id).copied().unwrap_or(0), + "active_member_count": active_member_counts.get(&item.id).copied().unwrap_or(0), + }) + }) + .collect(); + + Json(json!({ + "items": items, + "total": total, + "metric": metric.as_str(), + "start_date": time_range.map(|value| value.start_date.to_string()), + "end_date": time_range.map(|value| value.end_date.to_string()), + "attribution": "current_membership", + })) + .into_response() +} + pub fn build_admin_stats_comparison_response( current_usage: &[StoredRequestUsageAudit], comparison_usage: &[StoredRequestUsageAudit], diff --git a/crates/aether-data/adapters/postgres/src/usage/mod.rs b/crates/aether-data/adapters/postgres/src/usage/mod.rs index 23f7615d3..e9f81ea60 100644 --- a/crates/aether-data/adapters/postgres/src/usage/mod.rs +++ b/crates/aether-data/adapters/postgres/src/usage/mod.rs @@ -1857,6 +1857,33 @@ fn usage_leaderboard_sql_fragments( } } +fn push_usage_user_scope( + builder: &mut QueryBuilder<'_, Postgres>, + column: &str, + user_id: Option<&str>, + user_ids: Option<&[String]>, +) { + if let Some(user_id) = user_id { + builder + .push(" AND ") + .push(column) + .push(" = ") + .push_bind(user_id.to_string()); + } + if let Some(user_ids) = user_ids { + if user_ids.is_empty() { + builder.push(" AND FALSE"); + } else { + builder.push(" AND ").push(column).push(" IN ("); + let mut separated = builder.separated(", "); + for user_id in user_ids { + separated.push_bind(user_id.clone()); + } + separated.push_unseparated(")"); + } + } +} + const LIST_RECENT_USAGE_AUDITS_PREFIX: &str = include_str!("queries/list_recent_usage_audits_prefix.sql"); @@ -3625,14 +3652,15 @@ OR (\"usage\".error_message IS NOT NULL AND BTRIM(\"usage\".error_message) <> '' start_day_utc: DateTime, end_day_utc: DateTime, user_id: Option<&str>, + user_ids: Option<&[String]>, ) -> Result { if start_day_utc >= end_day_utc { return Ok(StoredUsageAuditSummary::default()); } - let row = if let Some(user_id) = user_id { - sqlx::query( - r#" + let scoped_to_users = user_id.is_some() || user_ids.is_some(); + let mut builder = QueryBuilder::::new( + r#" SELECT COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests, COALESCE(SUM(input_tokens), 0)::BIGINT AS input_tokens, @@ -3657,57 +3685,27 @@ SELECT COALESCE(SUM(cache_read_cost), 0)::DOUBLE PRECISION AS cache_read_cost_usd, COALESCE(SUM(response_time_sum_ms), 0)::DOUBLE PRECISION AS total_response_time_ms, COALESCE(SUM(error_requests), 0)::BIGINT AS error_requests -FROM stats_user_daily -WHERE user_id = $1 - AND date >= $2 - AND date < $3 -"#, - ) - .bind(user_id) - .bind(start_day_utc) - .bind(end_day_utc) - .fetch_one(&self.pool) - .await - .map_postgres_err()? +FROM "#, + ); + builder.push(if scoped_to_users { + "stats_user_daily" } else { - sqlx::query( - r#" -SELECT - COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests, - COALESCE(SUM(input_tokens), 0)::BIGINT AS input_tokens, - COALESCE(SUM(output_tokens), 0)::BIGINT AS output_tokens, - COALESCE(SUM( - CASE - WHEN effective_input_tokens = 0 AND total_input_context = 0 AND input_tokens > 0 - THEN input_tokens - ELSE effective_input_tokens - END - + output_tokens + cache_creation_tokens + cache_read_tokens - ), 0)::BIGINT AS recorded_total_tokens, - COALESCE(SUM(cache_creation_tokens), 0)::BIGINT AS cache_creation_tokens, - COALESCE(SUM(cache_creation_ephemeral_5m_tokens), 0)::BIGINT - AS cache_creation_ephemeral_5m_tokens, - COALESCE(SUM(cache_creation_ephemeral_1h_tokens), 0)::BIGINT - AS cache_creation_ephemeral_1h_tokens, - COALESCE(SUM(cache_read_tokens), 0)::BIGINT AS cache_read_tokens, - COALESCE(SUM(total_cost), 0)::DOUBLE PRECISION AS total_cost_usd, - COALESCE(SUM(actual_total_cost), 0)::DOUBLE PRECISION AS actual_total_cost_usd, - COALESCE(SUM(cache_creation_cost), 0)::DOUBLE PRECISION AS cache_creation_cost_usd, - COALESCE(SUM(cache_read_cost), 0)::DOUBLE PRECISION AS cache_read_cost_usd, - COALESCE(SUM(response_time_sum_ms), 0)::DOUBLE PRECISION AS total_response_time_ms, - COALESCE(SUM(error_requests), 0)::BIGINT AS error_requests -FROM stats_daily -WHERE date >= $1 - AND date < $2 -"#, - ) - .bind(start_day_utc) - .bind(end_day_utc) + "stats_daily" + }); + builder + .push(" WHERE date >= ") + .push_bind(start_day_utc) + .push(" AND date < ") + .push_bind(end_day_utc); + if scoped_to_users { + push_usage_user_scope(&mut builder, "user_id", user_id, user_ids); + } + + let row = builder + .build() .fetch_one(&self.pool) .await - .map_postgres_err()? - }; - + .map_postgres_err()?; decode_usage_audit_summary_row(&row) } @@ -3716,6 +3714,7 @@ WHERE date >= $1 created_from_unix_secs: u64, created_until_unix_secs: u64, user_id: Option<&str>, + user_ids: Option<&[String]>, provider_name: Option<&str>, model: Option<&str>, ) -> Result { @@ -3770,12 +3769,7 @@ FROM usage_billing_facts AS "usage" .push("\"usage\".created_at < TO_TIMESTAMP(") .push_bind(created_until_unix_secs as f64) .push("::double precision)"); - if let Some(user_id) = user_id { - builder.push(if has_where { " AND " } else { " WHERE " }); - builder - .push("\"usage\".user_id = ") - .push_bind(user_id.to_string()); - } + push_usage_user_scope(&mut builder, "\"usage\".user_id", user_id, user_ids); if let Some(provider_name) = provider_name { builder.push(if has_where { " AND " } else { " WHERE " }); has_where = true; @@ -3808,6 +3802,7 @@ FROM usage_billing_facts AS "usage" query.created_from_unix_secs, query.created_until_unix_secs, query.user_id.as_deref(), + query.user_ids.as_deref(), query.provider_name.as_deref(), query.model.as_deref(), ) @@ -3819,6 +3814,7 @@ FROM usage_billing_facts AS "usage" query.created_from_unix_secs, query.created_until_unix_secs, query.user_id.as_deref(), + query.user_ids.as_deref(), None, None, ) @@ -3834,6 +3830,7 @@ FROM usage_billing_facts AS "usage" query.created_from_unix_secs, query.created_until_unix_secs, query.user_id.as_deref(), + query.user_ids.as_deref(), None, None, ) @@ -3848,6 +3845,7 @@ FROM usage_billing_facts AS "usage" dashboard_utc_to_unix_secs(raw_start), dashboard_utc_to_unix_secs(raw_end), query.user_id.as_deref(), + query.user_ids.as_deref(), None, None, ) @@ -3861,6 +3859,7 @@ FROM usage_billing_facts AS "usage" aggregate_start, aggregate_end, query.user_id.as_deref(), + query.user_ids.as_deref(), ) .await?, ); @@ -3872,6 +3871,7 @@ FROM usage_billing_facts AS "usage" dashboard_utc_to_unix_secs(raw_start), dashboard_utc_to_unix_secs(raw_end), query.user_id.as_deref(), + query.user_ids.as_deref(), None, None, ) @@ -6815,13 +6815,12 @@ FROM usage_billing_facts AS "usage" .push("\"usage\".created_at < TO_TIMESTAMP(") .push_bind(query.created_until_unix_secs as f64) .push("::double precision)"); - if let Some(user_id) = query.user_id.as_deref() { - builder.push(if has_where { " AND " } else { " WHERE " }); - has_where = true; - builder - .push("\"usage\".user_id = ") - .push_bind(user_id.to_string()); - } + push_usage_user_scope( + &mut builder, + "\"usage\".user_id", + query.user_id.as_deref(), + query.user_ids.as_deref(), + ); if let Some(provider_name) = query.provider_name.as_deref() { builder.push(if has_where { " AND " } else { " WHERE " }); has_where = true; @@ -6861,62 +6860,45 @@ FROM usage_billing_facts AS "usage" start_day_utc: DateTime, end_day_utc: DateTime, user_id: Option<&str>, + user_ids: Option<&[String]>, ) -> Result, DataLayerError> { if start_day_utc >= end_day_utc { return Ok(Vec::new()); } - let rows = if let Some(user_id) = user_id { - sqlx::query( - r#" + let scoped_to_users = user_id.is_some() || user_ids.is_some(); + let mut builder = QueryBuilder::::new( + r#" SELECT TO_CHAR(date, 'YYYY-MM-DD') AS bucket_key, - total_requests::BIGINT AS total_requests, - input_tokens::BIGINT AS input_tokens, - output_tokens::BIGINT AS output_tokens, - cache_creation_tokens::BIGINT AS cache_creation_tokens, - cache_read_tokens::BIGINT AS cache_read_tokens, - CAST(total_cost AS DOUBLE PRECISION) AS total_cost_usd, - CAST(response_time_sum_ms AS DOUBLE PRECISION) AS total_response_time_ms -FROM stats_user_daily -WHERE user_id = $1 - AND date >= $2 - AND date < $3 -ORDER BY date ASC -"#, - ) - .bind(user_id) - .bind(start_day_utc) - .bind(end_day_utc) - .fetch_all(&self.pool) - .await - .map_postgres_err()? + COALESCE(SUM(total_requests), 0)::BIGINT AS total_requests, + COALESCE(SUM(input_tokens), 0)::BIGINT AS input_tokens, + COALESCE(SUM(output_tokens), 0)::BIGINT AS output_tokens, + COALESCE(SUM(cache_creation_tokens), 0)::BIGINT AS cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0)::BIGINT AS cache_read_tokens, + COALESCE(SUM(CAST(total_cost AS DOUBLE PRECISION)), 0) AS total_cost_usd, + COALESCE(SUM(CAST(response_time_sum_ms AS DOUBLE PRECISION)), 0) + AS total_response_time_ms +FROM "#, + ); + builder.push(if scoped_to_users { + "stats_user_daily" } else { - sqlx::query( - r#" -SELECT - TO_CHAR(date, 'YYYY-MM-DD') AS bucket_key, - total_requests::BIGINT AS total_requests, - input_tokens::BIGINT AS input_tokens, - output_tokens::BIGINT AS output_tokens, - cache_creation_tokens::BIGINT AS cache_creation_tokens, - cache_read_tokens::BIGINT AS cache_read_tokens, - CAST(total_cost AS DOUBLE PRECISION) AS total_cost_usd, - CAST(response_time_sum_ms AS DOUBLE PRECISION) AS total_response_time_ms -FROM stats_daily -WHERE date >= $1 - AND date < $2 -ORDER BY date ASC -"#, - ) - .bind(start_day_utc) - .bind(end_day_utc) - .fetch_all(&self.pool) - .await - .map_postgres_err()? - }; + "stats_daily" + }); + builder + .push(" WHERE date >= ") + .push_bind(start_day_utc) + .push(" AND date < ") + .push_bind(end_day_utc); + if scoped_to_users { + push_usage_user_scope(&mut builder, "user_id", user_id, user_ids); + } + builder.push(" GROUP BY date ORDER BY date ASC"); + + let mut rows = builder.build().fetch(&self.pool); let mut items = Vec::new(); - for row in rows { + while let Some(row) = rows.try_next().await.map_postgres_err()? { items.push(decode_usage_time_series_bucket_row(&row)?); } Ok(items) @@ -7002,6 +6984,7 @@ WHERE is_complete IS TRUE granularity: UsageTimeSeriesGranularity::Day, tz_offset_minutes: 0, user_id: query.user_id.clone(), + user_ids: query.user_ids.clone(), provider_name: None, model: None, }, @@ -7016,6 +6999,7 @@ WHERE is_complete IS TRUE aggregate_start, aggregate_end, query.user_id.as_deref(), + query.user_ids.as_deref(), ) .await?, ); @@ -7028,6 +7012,7 @@ WHERE is_complete IS TRUE granularity: UsageTimeSeriesGranularity::Day, tz_offset_minutes: 0, user_id: query.user_id.clone(), + user_ids: query.user_ids.clone(), provider_name: None, model: None, }, @@ -7045,6 +7030,7 @@ WHERE is_complete IS TRUE granularity: UsageTimeSeriesGranularity::Day, tz_offset_minutes: 0, user_id: query.user_id.clone(), + user_ids: query.user_ids.clone(), provider_name: None, model: None, }, @@ -7058,7 +7044,8 @@ WHERE is_complete IS TRUE } } - if query.user_id.is_none() && query.tz_offset_minutes % 60 == 0 { + if query.user_id.is_none() && query.user_ids.is_none() && query.tz_offset_minutes % 60 == 0 + { if let Some(cutoff_utc) = self.read_stats_hourly_cutoff().await? { let start_utc = dashboard_unix_secs_to_utc(query.created_from_unix_secs); let end_utc = dashboard_unix_secs_to_utc(query.created_until_unix_secs); @@ -7075,6 +7062,7 @@ WHERE is_complete IS TRUE granularity: query.granularity, tz_offset_minutes: query.tz_offset_minutes, user_id: None, + user_ids: None, provider_name: None, model: None, }, @@ -7102,6 +7090,7 @@ WHERE is_complete IS TRUE granularity: query.granularity, tz_offset_minutes: query.tz_offset_minutes, user_id: None, + user_ids: None, provider_name: None, model: None, }, @@ -7119,6 +7108,7 @@ WHERE is_complete IS TRUE granularity: query.granularity, tz_offset_minutes: query.tz_offset_minutes, user_id: None, + user_ids: None, provider_name: None, model: None, }, @@ -7158,6 +7148,7 @@ WHERE "usage".created_at >= TO_TIMESTAMP($1::double precision) AND ($3::varchar IS NULL OR "usage".user_id = $3) AND ($4::varchar IS NULL OR "usage".provider_name = $4) AND ($5::varchar IS NULL OR "usage".model = $5) + AND ($6::text[] IS NULL OR "usage".user_id::text = ANY($6)) GROUP BY group_key ORDER BY group_key ASC "#, @@ -7171,6 +7162,7 @@ ORDER BY group_key ASC .bind(query.user_id.as_deref()) .bind(query.provider_name.as_deref()) .bind(query.model.as_deref()) + .bind(query.user_ids.clone()) .fetch(&self.pool); let mut items = Vec::new(); while let Some(row) = rows.try_next().await.map_postgres_err()? { @@ -7314,11 +7306,12 @@ WHERE date >= .push_bind(end_day_utc) .push(" AND provider_name = ") .push_bind(provider_name.to_string()); - if let Some(user_id) = query.user_id.as_deref() { - builder - .push(" AND user_id = ") - .push_bind(user_id.to_string()); - } + push_usage_user_scope( + &mut builder, + "user_id", + query.user_id.as_deref(), + query.user_ids.as_deref(), + ); builder.push(" GROUP BY user_id ORDER BY user_id ASC"); builder } else if let Some(model) = query.model.as_deref() { @@ -7340,11 +7333,12 @@ WHERE date >= .push_bind(end_day_utc) .push(" AND model = ") .push_bind(model.to_string()); - if let Some(user_id) = query.user_id.as_deref() { - builder - .push(" AND user_id = ") - .push_bind(user_id.to_string()); - } + push_usage_user_scope( + &mut builder, + "user_id", + query.user_id.as_deref(), + query.user_ids.as_deref(), + ); builder.push(" GROUP BY user_id ORDER BY user_id ASC"); builder } else { @@ -7368,11 +7362,12 @@ WHERE date >= .push(" AND date < ") .push_bind(end_day_utc) .push(" AND user_id IS NOT NULL"); - if let Some(user_id) = query.user_id.as_deref() { - builder - .push(" AND user_id = ") - .push_bind(user_id.to_string()); - } + push_usage_user_scope( + &mut builder, + "user_id", + query.user_id.as_deref(), + query.user_ids.as_deref(), + ); builder.push(" GROUP BY user_id ORDER BY user_id ASC"); builder }; diff --git a/crates/aether-data/adapters/postgres/src/usage/tests.rs b/crates/aether-data/adapters/postgres/src/usage/tests.rs index a1cef6095..ee140363b 100644 --- a/crates/aether-data/adapters/postgres/src/usage/tests.rs +++ b/crates/aether-data/adapters/postgres/src/usage/tests.rs @@ -3251,13 +3251,15 @@ fn usage_sql_canonical_openai_cache_case_preserves_effective_and_total_tokens() aggregate_audit_summary .matches("WHEN effective_input_tokens = 0 AND total_input_context = 0") .count(), - 2 + 1, + "the shared daily aggregate query should define the legacy token fallback once" ); assert_eq!( aggregate_audit_summary .matches("+ output_tokens + cache_creation_tokens + cache_read_tokens") .count(), - 2 + 1, + "the shared daily aggregate query should define canonical total tokens once" ); assert!(!aggregate_audit_summary.contains("SUM(input_tokens + output_tokens)")); diff --git a/crates/aether-data/contracts/src/repository/usage/types.rs b/crates/aether-data/contracts/src/repository/usage/types.rs index a2b88639a..c266625e8 100644 --- a/crates/aether-data/contracts/src/repository/usage/types.rs +++ b/crates/aether-data/contracts/src/repository/usage/types.rs @@ -1063,6 +1063,9 @@ pub struct UsageAuditSummaryQuery { pub created_from_unix_secs: u64, pub created_until_unix_secs: u64, pub user_id: Option, + /// Optional bulk user scope used by current user-group reporting. + /// An empty list intentionally matches no usage rows. + pub user_ids: Option>, pub provider_name: Option, pub model: Option, } @@ -1455,6 +1458,9 @@ pub struct UsageTimeSeriesQuery { pub granularity: UsageTimeSeriesGranularity, pub tz_offset_minutes: i32, pub user_id: Option, + /// Optional bulk user scope used by current user-group reporting. + /// An empty list intentionally matches no usage rows. + pub user_ids: Option>, pub provider_name: Option, pub model: Option, } @@ -1485,6 +1491,9 @@ pub struct UsageLeaderboardQuery { pub created_until_unix_secs: u64, pub group_by: UsageLeaderboardGroupBy, pub user_id: Option, + /// Optional bulk user scope used by current user-group reporting. + /// An empty list intentionally matches no usage rows. + pub user_ids: Option>, pub provider_name: Option, pub model: Option, } diff --git a/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs b/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs index d1549c3d5..02fa33e24 100644 --- a/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs +++ b/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs @@ -2388,6 +2388,7 @@ INSERT INTO public.stats_daily_api_key ( .expect("historical stats end should be nonnegative"), group_by: UsageLeaderboardGroupBy::ApiKey, user_id: Some("leaderboard-owner".to_string()), + user_ids: None, provider_name: None, model: None, }; diff --git a/crates/aether-data/runtime/src/repository/usage/memory.rs b/crates/aether-data/runtime/src/repository/usage/memory.rs index af0fa9b63..da40b1b72 100644 --- a/crates/aether-data/runtime/src/repository/usage/memory.rs +++ b/crates/aether-data/runtime/src/repository/usage/memory.rs @@ -493,6 +493,15 @@ fn usage_matches_summary_query( return false; } } + if let Some(user_ids) = query.user_ids.as_deref() { + if !item + .user_id + .as_ref() + .is_some_and(|user_id| user_ids.contains(user_id)) + { + return false; + } + } if let Some(provider_name) = query.provider_name.as_deref() { if item.provider_name != provider_name { return false; @@ -520,6 +529,15 @@ fn usage_matches_time_series_query( return false; } } + if let Some(user_ids) = query.user_ids.as_deref() { + if !item + .user_id + .as_ref() + .is_some_and(|user_id| user_ids.contains(user_id)) + { + return false; + } + } if let Some(provider_name) = query.provider_name.as_deref() { if item.provider_name != provider_name { return false; @@ -891,6 +909,15 @@ fn usage_matches_leaderboard_query( return false; } } + if let Some(user_ids) = query.user_ids.as_deref() { + if !item + .user_id + .as_ref() + .is_some_and(|user_id| user_ids.contains(user_id)) + { + return false; + } + } if let Some(provider_name) = query.provider_name.as_deref() { if item.provider_name != provider_name { return false; diff --git a/crates/aether-data/runtime/src/repository/usage/memory/tests.rs b/crates/aether-data/runtime/src/repository/usage/memory/tests.rs index 17bd0a7e9..d15e6951b 100644 --- a/crates/aether-data/runtime/src/repository/usage/memory/tests.rs +++ b/crates/aether-data/runtime/src/repository/usage/memory/tests.rs @@ -18,7 +18,7 @@ use aether_data_contracts::repository::usage::{ UsageAuditAggregationQuery, UsageAuditKeywordSearchQuery, UsageAuditListQuery, UsageAuditSummaryQuery, UsageBodyCaptureState, UsageBodyField, UsageDashboardSummaryQuery, UsageLeaderboardGroupBy, UsageLeaderboardQuery, UsageProviderPerformanceQuery, - UsageTimeSeriesGranularity, + UsageTimeSeriesGranularity, UsageTimeSeriesQuery, }; use serde_json::json; @@ -2217,6 +2217,7 @@ async fn dashboard_and_leaderboard_total_tokens_use_effective_cache_aware_tokens created_until_unix_secs: 1_711_000_001, group_by: UsageLeaderboardGroupBy::User, user_id: None, + user_ids: None, provider_name: None, model: None, }) @@ -2226,6 +2227,64 @@ async fn dashboard_and_leaderboard_total_tokens_use_effective_cache_aware_tokens assert_eq!(leaderboard[0].total_tokens, 120); } +#[tokio::test] +async fn usage_analytics_filters_by_multiple_user_ids() { + let user_one = sample_usage("req-user-1", 1_711_000_000); + let mut user_two = sample_usage("req-user-2", 1_711_000_000); + user_two.user_id = Some("user-2".to_string()); + let mut user_three = sample_usage("req-user-3", 1_711_000_000); + user_three.user_id = Some("user-3".to_string()); + let repository = InMemoryUsageReadRepository::seed(vec![user_one, user_two, user_three]); + let scoped_user_ids = vec!["user-1".to_string(), "user-2".to_string()]; + + let summary = repository + .summarize_usage_audits(&UsageAuditSummaryQuery { + created_from_unix_secs: 1_711_000_000, + created_until_unix_secs: 1_711_000_001, + user_ids: Some(scoped_user_ids.clone()), + ..Default::default() + }) + .await + .expect("summary should filter by multiple users"); + assert_eq!(summary.total_requests, 2); + + let buckets = repository + .summarize_usage_time_series(&UsageTimeSeriesQuery { + created_from_unix_secs: 1_711_000_000, + created_until_unix_secs: 1_711_000_001, + granularity: UsageTimeSeriesGranularity::Day, + tz_offset_minutes: 0, + user_id: None, + user_ids: Some(scoped_user_ids.clone()), + provider_name: None, + model: None, + }) + .await + .expect("time series should filter by multiple users"); + assert_eq!( + buckets + .iter() + .map(|bucket| bucket.total_requests) + .sum::(), + 2 + ); + + let leaderboard = repository + .summarize_usage_leaderboard(&UsageLeaderboardQuery { + created_from_unix_secs: 1_711_000_000, + created_until_unix_secs: 1_711_000_001, + group_by: UsageLeaderboardGroupBy::User, + user_id: None, + user_ids: Some(scoped_user_ids), + provider_name: None, + model: None, + }) + .await + .expect("leaderboard should filter by multiple users"); + assert_eq!(leaderboard.len(), 2); + assert!(leaderboard.iter().all(|item| item.group_key != "user-3")); +} + #[tokio::test] async fn summarizes_provider_api_key_last_used_at_in_seconds() { let repository = InMemoryUsageReadRepository::seed(vec![ diff --git a/frontend/src/api/__tests__/admin-analytics-cache.spec.ts b/frontend/src/api/__tests__/admin-analytics-cache.spec.ts index 776812243..a4a14c82b 100644 --- a/frontend/src/api/__tests__/admin-analytics-cache.spec.ts +++ b/frontend/src/api/__tests__/admin-analytics-cache.spec.ts @@ -82,4 +82,35 @@ describe('adminApi analytics cache options', () => { }) expect(getMock).toHaveBeenNthCalledWith(4, '/api/admin/stats/errors/distribution', { params }) }) + + it('requests the user group leaderboard with scoped cache parameters', async () => { + const groupParams = { + ...params, + metric: 'cost' as const, + offset: 10, + limit: 10, + include_inactive: true, + } + getMock.mockResolvedValueOnce({ + data: { items: [], total: 0, metric: 'cost', attribution: 'current_membership' }, + }) + + await expect(adminApi.getLeaderboardUserGroups(groupParams)).resolves.toMatchObject({ + attribution: 'current_membership', + }) + + expect(buildCacheKeyMock).toHaveBeenCalledWith( + 'admin:stats:leaderboard:user-groups', + groupParams + ) + expect(cachedRequestMock).toHaveBeenCalledWith( + 'admin:stats:leaderboard:user-groups', + expect.any(Function), + 20 * 1000 + ) + expect(getMock).toHaveBeenCalledWith('/api/admin/stats/leaderboard/user-groups', { + params: groupParams, + }) + }) + }) diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 86e8dc382..1010f67c0 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -709,6 +709,8 @@ export interface LeaderboardItem { requests: number tokens: number cost: number + member_count?: number + active_member_count?: number } export interface LeaderboardResponse { @@ -717,6 +719,7 @@ export interface LeaderboardResponse { metric: string start_date?: string | null end_date?: string | null + attribution?: 'current_membership' } export interface CostForecastResponse { @@ -1298,6 +1301,7 @@ export const adminApi = { model?: string include_inactive?: boolean exclude_admin?: boolean + user_group_id?: string }): Promise { const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params) return cachedRequest( @@ -1312,6 +1316,35 @@ export const adminApi = { ) }, + async getLeaderboardUserGroups(params?: { + start_date?: string + end_date?: string + preset?: string + timezone?: string + tz_offset_minutes?: number + metric?: 'requests' | 'tokens' | 'cost' + order?: 'asc' | 'desc' + limit?: number + offset?: number + provider_name?: string + model?: string + include_inactive?: boolean + exclude_admin?: boolean + }): Promise { + const cacheKey = buildCacheKey('admin:stats:leaderboard:user-groups', params) + return cachedRequest( + cacheKey, + async () => { + const response = await apiClient.get( + '/api/admin/stats/leaderboard/user-groups', + { params } + ) + return response.data + }, + 20 * 1000 + ) + }, + async getLeaderboardApiKeys(params?: { start_date?: string end_date?: string @@ -1595,6 +1628,7 @@ export const adminApi = { timezone?: string tz_offset_minutes?: number user_id?: string + user_group_id?: string model?: string provider_name?: string }, diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 12c76cd9f..88f67a499 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -134,6 +134,7 @@ export interface UsageByApiFormat { export interface UsageFilters { user_id?: string // UUID + user_group_id?: string // UUID provider_id?: string // UUID model?: string search?: string diff --git a/frontend/src/components/stats/LeaderboardTable.vue b/frontend/src/components/stats/LeaderboardTable.vue index f7e90208a..d9838acf8 100644 --- a/frontend/src/components/stats/LeaderboardTable.vue +++ b/frontend/src/components/stats/LeaderboardTable.vue @@ -8,17 +8,17 @@ @update:model-value="emitMetric" > - + - 请求数 + {{ t('stats.metric.requests') }} - Tokens + {{ t('stats.metric.tokens') }} - 成本 + {{ t('stats.metric.cost') }} @@ -36,25 +36,31 @@ class="p-6" >
- 排名 + {{ t('stats.column.rank') }} - 名称 - - 请求数 + {{ t('stats.column.name') }} + + {{ t('stats.column.members') }} - Tokens + {{ t('stats.metric.requests') }} - 成本 + {{ t('stats.metric.tokens') }} + + + {{ t('stats.metric.cost') }} @@ -62,11 +68,22 @@ {{ item.rank }} {{ item.name }} + + {{ item.active_member_count ?? 0 }} / {{ item.member_count ?? 0 }} + {{ item.requests }} @@ -102,6 +119,7 @@ import { TableRow } from '@/components/ui' import { formatCurrency, formatTokens } from '@/utils/format' +import { useI18n } from '@/i18n' import type { LeaderboardItem } from '@/api/admin' interface Props { @@ -110,17 +128,24 @@ interface Props { metric: 'requests' | 'tokens' | 'cost' loading?: boolean showMetricSelect?: boolean + showMemberCount?: boolean + selectable?: boolean } const props = withDefaults(defineProps(), { loading: false, - showMetricSelect: true + showMetricSelect: true, + showMemberCount: false, + selectable: false }) const emit = defineEmits<{ (e: 'update:metric', value: 'requests' | 'tokens' | 'cost'): void + (e: 'select', value: LeaderboardItem): void }>() +const { t } = useI18n() + const metric = computed(() => props.metric) function emitMetric(value: string) { diff --git a/frontend/src/i18n/messages.ts b/frontend/src/i18n/messages.ts index 2992c30b2..9aa571175 100644 --- a/frontend/src/i18n/messages.ts +++ b/frontend/src/i18n/messages.ts @@ -346,6 +346,44 @@ export const messages = { 'nav.usageStats': '使用统计', 'nav.operations': '运维总览', 'nav.userStats': '用户统计', + 'userStats.title': '用户统计', + 'userStats.description': '按用户或当前用户组成员查看排行榜与使用趋势', + 'userStats.scope.placeholder': '统计维度', + 'userStats.scope.user': '按用户', + 'userStats.scope.userGroup': '按用户组', + 'userStats.search.user': '搜索用户', + 'userStats.search.userGroup': '搜索用户组', + 'userStats.select.user': '选择用户', + 'userStats.select.userGroup': '选择用户组', + 'userStats.compare.placeholder': '对比对象(可选)', + 'userStats.compare.none': '不对比', + 'userStats.leaderboard.user': '用户排行榜', + 'userStats.leaderboard.userGroup': '用户组排行榜', + 'userStats.pagination.summary': '共 {total} 项 · 第 {page} 页', + 'userStats.pagination.previous': '上一页', + 'userStats.pagination.next': '下一页', + 'userStats.summary.user': '用户摘要', + 'userStats.summary.userGroup': '用户组摘要', + 'userStats.selectPrompt': '请选择统计对象', + 'userStats.members.current': '当前成员', + 'userStats.members.active': '活跃成员', + 'userStats.memberLeaderboard': '组内成员排行', + 'userStats.trend.user': '用户使用趋势', + 'userStats.trend.userGroup': '用户组使用趋势', + 'userStats.comparisonTrend.user': '用户对比趋势', + 'userStats.comparisonTrend.userGroup': '用户组对比趋势', + 'userStats.chart.current': '当前对象', + 'userStats.chart.comparison': '对比对象', + 'stats.metric.placeholder': '指标', + 'stats.metric.requests': '请求数', + 'stats.metric.tokens': 'Tokens', + 'stats.metric.cost': '成本', + 'stats.metric.errorRate': '错误率', + 'stats.empty.title': '暂无数据', + 'stats.empty.description': '当前时间范围内没有统计结果', + 'stats.column.rank': '排名', + 'stats.column.name': '名称', + 'stats.column.members': '成员', 'nav.costAnalysis': '成本分析', 'nav.performanceAnalysis': '性能分析', 'nav.userManagement': '用户管理', @@ -792,6 +830,44 @@ export const messages = { 'nav.usageStats': 'Usage stats', 'nav.operations': 'Operations', 'nav.userStats': 'User stats', + 'userStats.title': 'User statistics', + 'userStats.description': 'View leaderboards and usage trends by user or current user-group membership', + 'userStats.scope.placeholder': 'Statistics scope', + 'userStats.scope.user': 'By user', + 'userStats.scope.userGroup': 'By user group', + 'userStats.search.user': 'Search users', + 'userStats.search.userGroup': 'Search user groups', + 'userStats.select.user': 'Select a user', + 'userStats.select.userGroup': 'Select a user group', + 'userStats.compare.placeholder': 'Comparison (optional)', + 'userStats.compare.none': 'No comparison', + 'userStats.leaderboard.user': 'User leaderboard', + 'userStats.leaderboard.userGroup': 'User-group leaderboard', + 'userStats.pagination.summary': '{total} items · Page {page}', + 'userStats.pagination.previous': 'Previous', + 'userStats.pagination.next': 'Next', + 'userStats.summary.user': 'User summary', + 'userStats.summary.userGroup': 'User-group summary', + 'userStats.selectPrompt': 'Select a statistics entity', + 'userStats.members.current': 'Current members', + 'userStats.members.active': 'Active members', + 'userStats.memberLeaderboard': 'Group member leaderboard', + 'userStats.trend.user': 'User usage trend', + 'userStats.trend.userGroup': 'User-group usage trend', + 'userStats.comparisonTrend.user': 'User comparison trend', + 'userStats.comparisonTrend.userGroup': 'User-group comparison trend', + 'userStats.chart.current': 'Current entity', + 'userStats.chart.comparison': 'Comparison entity', + 'stats.metric.placeholder': 'Metric', + 'stats.metric.requests': 'Requests', + 'stats.metric.tokens': 'Tokens', + 'stats.metric.cost': 'Cost', + 'stats.metric.errorRate': 'Error rate', + 'stats.empty.title': 'No data', + 'stats.empty.description': 'No statistics are available for the current time range', + 'stats.column.rank': 'Rank', + 'stats.column.name': 'Name', + 'stats.column.members': 'Members', 'nav.costAnalysis': 'Cost analysis', 'nav.performanceAnalysis': 'Performance analysis', 'nav.userManagement': 'Users', diff --git a/frontend/src/views/admin/UserStats.vue b/frontend/src/views/admin/UserStats.vue index 71f6e5768..f4e2316dc 100644 --- a/frontend/src/views/admin/UserStats.vue +++ b/frontend/src/views/admin/UserStats.vue @@ -1,70 +1,117 @@ From 166de33355188e7a81e52305c5fbe1475d9ff538 Mon Sep 17 00:00:00 2001 From: Kayphoon <109347466+Kayphoon@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:44:28 +0000 Subject: [PATCH 24/72] fix(responses): keep raw reasoning on content only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw chain-of-thought was written to both `content` (`reasoning_text`) and `summary` (`summary_text`), and the stream emitter sent the same delta on `response.reasoning_text.delta` *and* `response.reasoning_summary_text.delta`. Clients that render both channels therefore printed every thinking chunk twice — most visibly the Codex CLI, whose thinking panel repeated itself. OpenAI keeps the two channels distinct: `content` carries the raw CoT while `summary` is the summarised view. Emit the thinking on `content` only: - `openai_responses_reasoning_text_fields` becomes `openai_responses_reasoning_text_parts`, returning just the `content` array; reasoning items keep `summary: []` (or a provider-supplied summary). - The Responses stream emitter emits `response.reasoning_text.delta` / `.done` and no longer mirrors them onto the summary events. The reasoning `output_item.added` no longer announces a `reasoning_summary_part`. - The provider-state reasoning reader accepts `content` (`reasoning_text`) first and falls back to `summary`, so it also understands items produced by older Aether versions; its state field is renamed accordingly. - Non-streaming builders (Chat -> Responses, manual Responses response, Grok gateway) place the thinking on `content` and leave `summary` empty. Tests cover the raw thinking appearing exactly once in the emitted stream. --- .../src/execution_runtime/grok.rs | 17 +- .../src/execution_runtime/stream/execution.rs | 6 +- .../src/formats/conversion/response.rs | 12 +- .../formats/src/formats/openai/chat/stream.rs | 228 ++++++++++-------- .../src/formats/openai/responses/mod.rs | 61 ++--- .../src/formats/openai/responses/response.rs | 5 +- .../shared/stream_core/format_matrix.rs | 2 +- .../src/formats/shared/sync_products.rs | 39 +-- .../formats/src/protocol/canonical.rs | 3 +- 9 files changed, 193 insertions(+), 180 deletions(-) diff --git a/apps/aether-gateway/src/execution_runtime/grok.rs b/apps/aether-gateway/src/execution_runtime/grok.rs index 340a5a5f3..53bb26ff4 100644 --- a/apps/aether-gateway/src/execution_runtime/grok.rs +++ b/apps/aether-gateway/src/execution_runtime/grok.rs @@ -3203,10 +3203,7 @@ fn openai_responses_body( "id": openai_responses_synthetic_reasoning_item_id(&response_id, 0), "type": "reasoning", "status": "completed", - "summary": [{ - "type": "summary_text", - "text": thinking, - }], + "summary": [], "content": [{ "type": "reasoning_text", "text": thinking, @@ -4640,10 +4637,7 @@ mod tests { body["output"][0]["content"][0]["text"], serde_json::json!("short reasoning") ); - assert_eq!( - body["output"][0]["summary"][0]["text"], - serde_json::json!("short reasoning") - ); + assert_eq!(body["output"][0]["summary"], serde_json::json!([])); assert_eq!(body["output"][1]["type"], serde_json::json!("message")); assert!(body["output"][1]["id"] .as_str() @@ -4827,7 +4821,12 @@ mod tests { assert!(body.contains("event: response.created")); assert!(body.contains("event: response.in_progress")); - assert!(body.contains("event: response.reasoning_summary_part.added")); + // Thinking must stay off the summary channel or clients that render + // both (Codex) print the raw chain-of-thought twice. + assert!(!body.contains("event: response.reasoning_summary_part.added")); + assert!(!body.contains("event: response.reasoning_summary_text.delta")); + assert!(!body.contains("event: response.reasoning_summary_text.done")); + assert!(body.contains("\"type\":\"reasoning_text\"")); assert!(body.contains("event: response.content_part.added")); assert!(body.contains("event: response.output_text.done")); assert!(body.contains("event: response.completed")); diff --git a/apps/aether-gateway/src/execution_runtime/stream/execution.rs b/apps/aether-gateway/src/execution_runtime/stream/execution.rs index d9d30a4c6..8b7948968 100644 --- a/apps/aether-gateway/src/execution_runtime/stream/execution.rs +++ b/apps/aether-gateway/src/execution_runtime/stream/execution.rs @@ -11880,7 +11880,11 @@ mod tests { .expect("response body should read"); let body = String::from_utf8(body.to_vec()).expect("response body should be utf8"); assert!( - body.contains("event: response.reasoning_summary_text.delta\n"), + body.contains("event: response.reasoning_text.delta\n"), + "{body}" + ); + assert!( + !body.contains("event: response.reasoning_summary_text.delta\n"), "{body}" ); assert!( diff --git a/crates/aether-ai/formats/src/formats/conversion/response.rs b/crates/aether-ai/formats/src/formats/conversion/response.rs index a10863e6b..41b7492d4 100644 --- a/crates/aether-ai/formats/src/formats/conversion/response.rs +++ b/crates/aether-ai/formats/src/formats/conversion/response.rs @@ -9,7 +9,7 @@ use serde_json::{json, Value}; use crate::formats::{ context::FormatContext, openai::responses::{ - openai_responses_message_item_id, openai_responses_reasoning_text_fields, + openai_responses_message_item_id, openai_responses_reasoning_text_parts, openai_responses_synthetic_reasoning_item_id, response::ensure_modern_openai_responses_response_fields, }, @@ -206,12 +206,12 @@ pub fn build_openai_responses_response_with_content( if trimmed.is_empty() { continue; } - let (content, summary) = openai_responses_reasoning_text_fields(std::iter::once(trimmed)); + let content = openai_responses_reasoning_text_parts(std::iter::once(trimmed)); output.push(json!({ "type": "reasoning", "id": openai_responses_synthetic_reasoning_item_id(response_id, index), "status": "completed", - "summary": summary, + "summary": [], "content": content, })); } @@ -310,8 +310,7 @@ mod tests { "reasoning_text" ); assert_eq!(response["output"][0]["content"][0]["text"], "raw thinking"); - assert_eq!(response["output"][0]["summary"][0]["type"], "summary_text"); - assert_eq!(response["output"][0]["summary"][0]["text"], "raw thinking"); + assert_eq!(response["output"][0]["summary"], json!([])); assert_eq!(response["output"][1]["content"][0]["text"], "answer"); } @@ -357,8 +356,7 @@ mod tests { assert_eq!(item["type"], "reasoning"); assert_eq!(item["content"][0]["type"], "reasoning_text"); assert_eq!(item["content"][0]["text"], "compare the decimals"); - assert_eq!(item["summary"][0]["type"], "summary_text"); - assert_eq!(item["summary"][0]["text"], "compare the decimals"); + assert_eq!(item["summary"], json!([])); assert!(!item.get("content").unwrap().is_null()); assert_eq!(converted["output"][1]["type"], "message"); assert_eq!( diff --git a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs index 1261921ed..2d12729e2 100644 --- a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs +++ b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs @@ -7,7 +7,7 @@ use crate::formats::openai::chat::response::openai_chat_reasoning_texts; use crate::formats::openai::namespace::NamespaceToolAliases; use crate::formats::openai::responses::{ encode_gemini_tool_signature_carrier_with_direction, openai_responses_message_item_id, - openai_responses_reasoning_text_fields, openai_responses_synthetic_reasoning_item_id, + openai_responses_reasoning_text_parts, openai_responses_synthetic_reasoning_item_id, response::{ ensure_modern_openai_responses_response_fields, openai_responses_current_timestamp, }, @@ -1213,40 +1213,13 @@ impl OpenAIResponsesProviderState { if item.get("type").and_then(Value::as_str) != Some("reasoning") { return; } + let completed_reasoning = reasoning_item_text(item); if self.terminal_only { - if item - .get("summary") - .and_then(Value::as_array) - .is_some_and(|summary| { - summary.iter().any(|part| { - part.get("type").and_then(Value::as_str) == Some("summary_text") - && part - .get("text") - .and_then(Value::as_str) - .is_some_and(|text| !text.is_empty()) - }) - }) - { + if !completed_reasoning.is_empty() { self.ensure_started(report_context, out); } return; } - let mut completed_reasoning = String::new(); - for raw_summary in item - .get("summary") - .and_then(Value::as_array) - .into_iter() - .flatten() - { - let Some(summary) = raw_summary.as_object() else { - continue; - }; - if summary.get("type").and_then(Value::as_str) == Some("summary_text") { - if let Some(text) = summary.get("text").and_then(Value::as_str) { - completed_reasoning.push_str(text); - } - } - } if !completed_reasoning.is_empty() { self.emit_missing_reasoning(report_context, out, &completed_reasoning); } @@ -1663,7 +1636,8 @@ impl OpenAIResponsesProviderState { .unwrap_or_default(); if !piece.is_empty() { let summary_index = value - .get("summary_index") + .get("content_index") + .or_else(|| value.get("summary_index")) .and_then(Value::as_u64) .map(|value| value as usize) .unwrap_or(0); @@ -1698,7 +1672,8 @@ impl OpenAIResponsesProviderState { .unwrap_or_default(); if !text.is_empty() { let summary_index = value - .get("summary_index") + .get("content_index") + .or_else(|| value.get("summary_index")) .and_then(Value::as_u64) .map(|value| value as usize) .unwrap_or(0); @@ -2095,6 +2070,29 @@ impl OpenAIResponsesProviderState { } } +/// Reads a Responses reasoning item's raw chain-of-thought. +/// +/// Raw thinking lives on `content` (`reasoning_text` parts); `summary` is the +/// summarised view and is only consulted when `content` carries nothing, so +/// items produced by other Aether versions still yield their thinking. +fn reasoning_item_text(item: &Map) -> String { + let mut text = reasoning_item_parts_text(item.get("content"), "reasoning_text"); + if text.is_empty() { + text = reasoning_item_parts_text(item.get("summary"), "summary_text"); + } + text +} + +fn reasoning_item_parts_text(raw: Option<&Value>, expected_type: &str) -> String { + raw.and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_object) + .filter(|part| part.get("type").and_then(Value::as_str) == Some(expected_type)) + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::() +} + #[derive(Default)] pub struct OpenAIChatClientEmitter { response_id: Option, @@ -2677,12 +2675,14 @@ impl OpenAIResponsesClientEmitter { } fn reasoning_item_value(&self) -> Value { - let (content, summary) = openai_responses_reasoning_text_fields(self.reasoning_texts()); + let content = openai_responses_reasoning_text_parts(self.reasoning_texts()); json!({ "type": "reasoning", "id": self.reasoning_item_id(), "status": "completed", - "summary": summary, + // Raw thinking goes on `content` only. Mirroring it onto `summary` + // makes Codex (which renders both channels) print it twice. + "summary": [], "content": content, }) } @@ -2694,29 +2694,17 @@ impl OpenAIResponsesClientEmitter { let item_id = self.reasoning_item_id(); let output_index = self.reasoning_output_index.unwrap_or(0); let part_index = self.current_reasoning_summary_index(); - let mut out = self.encode_response_event( + self.encode_response_event( "response.reasoning_text.delta", json!({ "type": "response.reasoning_text.delta", "response_id": self.response_id(), - "item_id": item_id.clone(), + "item_id": item_id, "output_index": output_index, "content_index": part_index, "delta": text, }), - )?; - out.extend(self.encode_response_event( - "response.reasoning_summary_text.delta", - json!({ - "type": "response.reasoning_summary_text.delta", - "response_id": self.response_id(), - "item_id": item_id, - "output_index": output_index, - "summary_index": part_index, - "delta": text, - }), - )?); - Ok(out) + ) } fn encode_reasoning_text_done_events( @@ -2726,7 +2714,7 @@ impl OpenAIResponsesClientEmitter { part_index: usize, part_text: &str, ) -> Result, AiSurfaceFinalizeError> { - let mut out = self.encode_response_event( + self.encode_response_event( "response.reasoning_text.done", json!({ "type": "response.reasoning_text.done", @@ -2736,33 +2724,7 @@ impl OpenAIResponsesClientEmitter { "content_index": part_index, "text": part_text, }), - )?; - out.extend(self.encode_response_event( - "response.reasoning_summary_text.done", - json!({ - "type": "response.reasoning_summary_text.done", - "response_id": self.response_id(), - "item_id": item_id, - "output_index": output_index, - "summary_index": part_index, - "text": part_text, - }), - )?); - out.extend(self.encode_response_event( - "response.reasoning_summary_part.done", - json!({ - "type": "response.reasoning_summary_part.done", - "response_id": self.response_id(), - "item_id": item_id, - "output_index": output_index, - "summary_index": part_index, - "part": { - "type": "summary_text", - "text": part_text, - } - }), - )?); - Ok(out) + ) } fn ensure_message_output_index(&mut self) -> usize { @@ -2827,21 +2789,6 @@ impl OpenAIResponsesClientEmitter { self.reasoning_item_started = true; } if !self.reasoning_part_started { - let summary_index = self.current_reasoning_summary_index(); - out.extend(self.encode_response_event( - "response.reasoning_summary_part.added", - json!({ - "type": "response.reasoning_summary_part.added", - "response_id": self.response_id(), - "item_id": item_id, - "output_index": output_index, - "summary_index": summary_index, - "part": { - "type": "summary_text", - "text": "", - } - }), - )?); self.reasoning_part_started = true; } Ok(out) @@ -4291,7 +4238,7 @@ mod tests { data = Some(value); } } - if event_name != Some("response.reasoning_summary_text.done") { + if event_name != Some("response.reasoning_text.done") { continue; } let Some(data) = data else { @@ -4300,13 +4247,17 @@ mod tests { let Ok(value) = serde_json::from_str::(data) else { continue; }; - let Some(summary_index) = value.get("summary_index").and_then(Value::as_u64) else { + let Some(part_index) = value + .get("content_index") + .or_else(|| value.get("summary_index")) + .and_then(Value::as_u64) + else { continue; }; let Some(text) = value.get("text").and_then(Value::as_str) else { continue; }; - parts.push((summary_index, text.to_string())); + parts.push((part_index, text.to_string())); } parts } @@ -4527,7 +4478,8 @@ mod tests { } let sse = String::from_utf8(bytes).expect("sse should be utf8"); - assert!(sse.contains("event: response.reasoning_summary_text.delta\n")); + assert!(sse.contains("event: response.reasoning_text.delta\n")); + assert!(!sse.contains("event: response.reasoning_summary_text.delta\n")); assert!(sse.contains("\"delta\":\"Let\"")); assert!(sse.contains("\"delta\":\" me think.\"")); } @@ -6596,20 +6548,19 @@ mod tests { ); let sse = String::from_utf8(bytes).expect("sse should be utf8"); - assert!(sse.contains("event: response.reasoning_summary_part.added\n")); assert!(sse.contains("event: response.reasoning_text.delta\n")); - assert!(sse.contains("event: response.reasoning_summary_text.delta\n")); assert!(sse.contains("event: response.reasoning_text.done\n")); - assert!(sse.contains("event: response.reasoning_summary_text.done\n")); - assert!(sse.contains("event: response.reasoning_summary_part.done\n")); assert!(sse.contains("\"type\":\"reasoning_text\"")); + // Raw chain-of-thought must not be duplicated onto the summary channel: + // Codex renders both, so emitting both makes the thinking panel repeat. + assert!(!sse.contains("event: response.reasoning_summary_text.delta\n")); + assert!(!sse.contains("event: response.reasoning_summary_text.done\n")); + assert!(!sse.contains("event: response.reasoning_summary_part.added\n")); + assert!(!sse.contains("event: response.reasoning_summary_part.done\n")); let reasoning_item_id = openai_responses_synthetic_reasoning_item_id("resp_456", 0); assert!(sse.contains(&format!("\"item_id\":\"{reasoning_item_id}\""))); assert!(sse.contains("\"type\":\"reasoning\"")); - assert_eq!( - response_sequence_numbers(&sse), - (1..=11).collect::>() - ); + assert_eq!(response_sequence_numbers(&sse), (1..=7).collect::>()); } #[test] @@ -6678,6 +6629,75 @@ mod tests { ); } + /// Regression: raw thinking must reach the client exactly once. + /// + /// Codex renders both the `content` (`reasoning_text`) and `summary` + /// (`summary_text`) channels, so emitting the same chain-of-thought on both + /// made its thinking panel print every line twice. + #[test] + fn openai_responses_client_emitter_sends_raw_thinking_once() { + let mut emitter = OpenAIResponsesClientEmitter::default(); + let mut bytes = emitter + .emit(CanonicalStreamFrame { + id: "resp_once".to_string(), + model: "gpt-5.4".to_string(), + event: CanonicalStreamEvent::Start, + }) + .expect("start should encode"); + for text in ["Let", " me", " think."] { + bytes.extend( + emitter + .emit(CanonicalStreamFrame { + id: "resp_once".to_string(), + model: "gpt-5.4".to_string(), + event: CanonicalStreamEvent::ReasoningDelta(text.to_string()), + }) + .expect("reasoning delta should encode"), + ); + } + bytes.extend( + emitter + .emit(CanonicalStreamFrame { + id: "resp_once".to_string(), + model: "gpt-5.4".to_string(), + event: CanonicalStreamEvent::ReasoningSummaryDone, + }) + .expect("reasoning boundary should encode"), + ); + bytes.extend( + emitter + .emit(CanonicalStreamFrame { + id: "resp_once".to_string(), + model: "gpt-5.4".to_string(), + event: CanonicalStreamEvent::Finish { + finish_reason: Some("stop".to_string()), + usage: None, + }, + }) + .expect("finish should encode"), + ); + + let sse = String::from_utf8(bytes).expect("sse should be utf8"); + // Each thinking chunk is streamed on exactly one channel. The same delta + // used to be mirrored onto `reasoning_summary_text.delta`, so clients that + // render both channels (Codex) printed every chunk twice. + assert_eq!( + sse.matches("event: response.reasoning_text.delta\n") + .count(), + 3, + "one delta event per thinking chunk: {sse}" + ); + assert!(!sse.contains("event: response.reasoning_summary_text.delta\n")); + assert!(!sse.contains("event: response.reasoning_summary_text.done\n")); + assert!(!sse.contains("\"type\":\"summary_text\"")); + // The completed item carries the thinking on `content`, not `summary`. + assert!( + sse.contains("\"content\":[{\"type\":\"reasoning_text\",\"text\":\"Let me think.\"}]"), + "{sse}" + ); + assert!(sse.contains("\"summary\":[]"), "{sse}"); + } + #[test] fn openai_responses_client_emitter_emits_failed_event_with_sequence_number() { let mut emitter = OpenAIResponsesClientEmitter::default(); diff --git a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs index 7e55a848a..e963df4b6 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs @@ -126,47 +126,39 @@ pub fn openai_responses_message_item_id(response_id: &str, output_index: usize) ) } -/// Builds Responses reasoning `content` / `summary` arrays from raw thinking text. +/// Builds the Responses reasoning `content` array from raw thinking text. /// -/// OpenAI Responses semantics: -/// - `content` holds raw chain-of-thought as `reasoning_text` parts. Desktop UIs -/// (for example Codex) hide the thinking panel when `content` is null. -/// - `summary` holds `summary_text` parts for skim / CLI clients. When the -/// upstream only exposes raw thinking (DeepSeek `reasoning_content`, Gemini -/// thoughts, Claude thinking), the same text is copied into both so neither -/// client family loses the panel. -pub(crate) fn openai_responses_reasoning_text_fields( +/// Raw chain-of-thought belongs in `content` as `reasoning_text` parts. It is +/// deliberately *not* mirrored into `summary`: OpenAI keeps the two channels +/// distinct, and clients such as Codex render both, so duplicating the same +/// text onto `summary` made the thinking panel print everything twice. +pub(crate) fn openai_responses_reasoning_text_parts( texts: impl IntoIterator>, -) -> (Value, Value) { - let texts: Vec = texts - .into_iter() - .map(|text| text.as_ref().to_string()) - .filter(|text| !text.trim().is_empty()) - .collect(); - let content = texts - .iter() - .map(|text| json!({ "type": "reasoning_text", "text": text })) - .collect::>(); - let summary = texts - .iter() - .map(|text| json!({ "type": "summary_text", "text": text })) - .collect::>(); - (Value::Array(content), Value::Array(summary)) +) -> Value { + Value::Array( + texts + .into_iter() + .map(|text| text.as_ref().to_string()) + .filter(|text| !text.trim().is_empty()) + .map(|text| json!({ "type": "reasoning_text", "text": text })) + .collect(), + ) } /// Writes raw thinking onto a Responses reasoning item without clobbering an -/// existing structured summary or provider-owned content. +/// existing provider-owned summary or content. pub(crate) fn apply_openai_responses_reasoning_text(item: &mut Map, text: &str) { if text.trim().is_empty() { return; } - let (content, summary) = openai_responses_reasoning_text_fields(std::iter::once(text)); if reasoning_item_field_is_empty(item.get("content")) { + let content = openai_responses_reasoning_text_parts(std::iter::once(text)); item.insert("content".to_string(), content); } - if reasoning_item_field_is_empty(item.get("summary")) { - item.insert("summary".to_string(), summary); - } + // `summary` stays a valid (empty) array so the item keeps its documented + // shape; a provider-supplied summary is preserved as-is. + item.entry("summary".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); } fn reasoning_item_field_is_empty(value: Option<&Value>) -> bool { @@ -540,21 +532,18 @@ mod tests { } #[test] - fn reasoning_text_fields_put_raw_thinking_in_content_and_summary() { - let (content, summary) = super::openai_responses_reasoning_text_fields(["raw chain"]); + fn reasoning_text_parts_put_raw_thinking_in_content_only() { + let content = super::openai_responses_reasoning_text_parts(["raw chain"]); assert_eq!( content, json!([{ "type": "reasoning_text", "text": "raw chain" }]) ); - assert_eq!( - summary, - json!([{ "type": "summary_text", "text": "raw chain" }]) - ); let mut item = serde_json::Map::new(); super::apply_openai_responses_reasoning_text(&mut item, "raw chain"); assert_eq!(item["content"], content); - assert_eq!(item["summary"], summary); + // Never mirrored onto `summary`: clients rendering both would repeat it. + assert_eq!(item["summary"], json!([])); item.insert( "summary".to_string(), diff --git a/crates/aether-ai/formats/src/formats/openai/responses/response.rs b/crates/aether-ai/formats/src/formats/openai/responses/response.rs index 0d25d4c82..2b0f85163 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/response.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/response.rs @@ -786,7 +786,7 @@ mod tests { } #[test] - fn responses_response_builder_puts_raw_thinking_in_content_and_summary() { + fn responses_response_builder_puts_raw_thinking_in_content_only() { let response = CanonicalResponse { id: "resp_think".to_string(), model: "deepseek-reasoner".to_string(), @@ -814,8 +814,7 @@ mod tests { assert_eq!(item["type"], "reasoning"); assert_eq!(item["content"][0]["type"], "reasoning_text"); assert_eq!(item["content"][0]["text"], "first add one to one"); - assert_eq!(item["summary"][0]["type"], "summary_text"); - assert_eq!(item["summary"][0]["text"], "first add one to one"); + assert_eq!(item["summary"], json!([])); assert!(!item["content"].is_null()); assert_eq!(body["output"][1]["type"], "message"); assert_eq!(body["output"][1]["content"][0]["text"], "2"); diff --git a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs index 680de2b08..4b05215a4 100644 --- a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs @@ -1053,7 +1053,7 @@ mod tests { "{sse}" ); assert!( - sse.contains("event: response.reasoning_summary_text.delta\n"), + !sse.contains("event: response.reasoning_summary_text.delta\n"), "{sse}" ); assert!(sse.contains("\"delta\":\"checking\""), "{sse}"); diff --git a/crates/aether-ai/formats/src/formats/shared/sync_products.rs b/crates/aether-ai/formats/src/formats/shared/sync_products.rs index 8ebfeb2b3..0bc9b58a3 100644 --- a/crates/aether-ai/formats/src/formats/shared/sync_products.rs +++ b/crates/aether-ai/formats/src/formats/shared/sync_products.rs @@ -9,7 +9,7 @@ use aether_ai_formats::formats::conversion::response::{ }; use aether_ai_formats::formats::openai::responses::response::ensure_modern_openai_responses_response_fields; use aether_ai_formats::formats::openai::responses::{ - openai_responses_message_item_id, openai_responses_reasoning_text_fields, + openai_responses_message_item_id, openai_responses_reasoning_text_parts, openai_responses_synthetic_reasoning_item_id, }; use aether_ai_formats::formats::registry::{convert_response, FormatContext, FormatError}; @@ -2471,7 +2471,7 @@ fn aggregate_openai_responses_stream_sync_response_from_validated_terminal( reasoning_states .entry(output_index) .or_default() - .summary_text + .reasoning_text .push_str(delta); } "response.reasoning_text.done" | "response.reasoning_summary_text.done" => { @@ -2799,7 +2799,7 @@ struct OpenAIResponsesSyncMessageState { #[derive(Default)] struct OpenAIResponsesSyncReasoningState { item: Map, - summary_text: String, + reasoning_text: String, } #[derive(Default)] @@ -3113,8 +3113,8 @@ fn merge_openai_responses_reasoning_text( if text.is_empty() { return; } - if state.summary_text.is_empty() || text.len() >= state.summary_text.len() { - state.summary_text = text.to_string(); + if state.reasoning_text.is_empty() || text.len() >= state.reasoning_text.len() { + state.reasoning_text = text.to_string(); } } @@ -3297,15 +3297,16 @@ fn materialize_openai_responses_reasoning_item( }); item.entry("status".to_string()) .or_insert_with(|| Value::String("completed".to_string())); - if !state.summary_text.is_empty() { - let (content, summary) = openai_responses_reasoning_text_fields([&state.summary_text]); - if reasoning_item_field_missing_or_empty(item.get("content")) { - item.insert("content".to_string(), content); - } - if reasoning_item_field_missing_or_empty(item.get("summary")) { - item.insert("summary".to_string(), summary); - } + if !state.reasoning_text.is_empty() + && reasoning_item_field_missing_or_empty(item.get("content")) + { + let content = openai_responses_reasoning_text_parts([&state.reasoning_text]); + item.insert("content".to_string(), content); } + // Raw chain-of-thought lives on `content` only; never mirror it onto + // `summary`, or clients that render both channels show it twice. + item.entry("summary".to_string()) + .or_insert_with(|| Value::Array(Vec::new())); Value::Object(item) } @@ -5728,7 +5729,9 @@ mod tests { .expect("modern response.done stream should aggregate"); assert_eq!(result["output"][0]["type"], "reasoning"); - assert_eq!(result["output"][0]["summary"][0]["text"], "Need care"); + assert_eq!(result["output"][0]["summary"], json!([])); + assert_eq!(result["output"][0]["content"][0]["type"], "reasoning_text"); + assert_eq!(result["output"][0]["content"][0]["text"], "Need care"); assert!(result["output"].as_array().is_some()); assert_eq!(result["output_text"], ""); assert!(result["completed_at"].as_i64().is_some()); @@ -5754,7 +5757,7 @@ mod tests { .as_object() .expect("reasoning item should be an object") .clone(), - summary_text: "must not replace provider-owned state".to_string(), + reasoning_text: "must not replace provider-owned state".to_string(), }; let materialized = materialize_openai_responses_reasoning_item("resp_opaque_123", state); @@ -5795,13 +5798,13 @@ mod tests { } #[test] - fn synthesizes_wire_compatible_id_for_local_reasoning_summary() { + fn synthesizes_wire_compatible_id_for_local_reasoning_text() { let state = OpenAIResponsesSyncReasoningState { item: json!({"type": "reasoning"}) .as_object() .expect("reasoning item should be an object") .clone(), - summary_text: "Need care".to_string(), + reasoning_text: "Need care".to_string(), }; let materialized = materialize_openai_responses_reasoning_item("resp_summary_123", state); @@ -5810,7 +5813,7 @@ mod tests { materialized["id"], openai_responses_synthetic_reasoning_item_id("resp_summary_123", 0) ); - assert_eq!(materialized["summary"][0]["text"], "Need care"); + assert_eq!(materialized["summary"], json!([])); assert_eq!(materialized["content"][0]["type"], "reasoning_text"); assert_eq!(materialized["content"][0]["text"], "Need care"); } diff --git a/crates/aether-ai/formats/src/protocol/canonical.rs b/crates/aether-ai/formats/src/protocol/canonical.rs index 02de35d91..8a1abc49d 100644 --- a/crates/aether-ai/formats/src/protocol/canonical.rs +++ b/crates/aether-ai/formats/src/protocol/canonical.rs @@ -8412,7 +8412,8 @@ mod tests { let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false) .expect("openai responses request"); assert_eq!(rebuilt["input"][0]["type"], "reasoning"); - assert_eq!(rebuilt["input"][0]["summary"][0]["text"], "think"); + assert_eq!(rebuilt["input"][0]["content"][0]["type"], "reasoning_text"); + assert_eq!(rebuilt["input"][0]["content"][0]["text"], "think"); assert_eq!(rebuilt["input"][0]["encrypted_content"], "enc_reasoning"); assert_eq!(rebuilt["input"][1]["type"], "message"); assert_eq!(rebuilt["input"][1]["content"][0]["text"], "done"); From 906baae88ec9702310eeb4de3d6bf4ad4e025bd5 Mon Sep 17 00:00:00 2001 From: dalamudx Date: Sat, 19 Sep 2026 22:37:29 +0800 Subject: [PATCH 25/72] fix(gemini): preserve tool thought signatures --- .../formats/src/formats/openai/chat/stream.rs | 176 +++++++++++++++++- 1 file changed, 172 insertions(+), 4 deletions(-) diff --git a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs index 2d12729e2..87d65f44f 100644 --- a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs +++ b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; @@ -6,8 +6,9 @@ use sha2::{Digest, Sha256}; use crate::formats::openai::chat::response::openai_chat_reasoning_texts; use crate::formats::openai::namespace::NamespaceToolAliases; use crate::formats::openai::responses::{ - encode_gemini_tool_signature_carrier_with_direction, openai_responses_message_item_id, - openai_responses_reasoning_text_parts, openai_responses_synthetic_reasoning_item_id, + decode_gemini_tool_signature_carrier, encode_gemini_tool_signature_carrier_with_direction, + openai_responses_message_item_id, openai_responses_reasoning_text_parts, + openai_responses_synthetic_reasoning_item_id, response::{ ensure_modern_openai_responses_response_fields, openai_responses_current_timestamp, }, @@ -78,6 +79,8 @@ pub struct OpenAIResponsesProviderState { tool_index_by_key: BTreeMap, image_item_keys: BTreeSet, opaque_completed_item_keys: BTreeSet, + seen_tool_signature_carriers: BTreeSet, + pending_tool_signatures: VecDeque, last_tool_index: Option, } @@ -870,6 +873,7 @@ impl OpenAIResponsesProviderState { return; } }; + self.emit_pending_tool_signature(report_context, out, index); let state = self.tool_calls.entry(index).or_default(); state.call_id = item .get("call_id") @@ -1204,6 +1208,68 @@ impl OpenAIResponsesProviderState { } } + fn capture_tool_signature_carrier( + &mut self, + report_context: &Value, + out: &mut Vec, + item: &Map, + ) { + if self.terminal_only { + return; + } + let Some(carrier) = item + .get("encrypted_content") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + else { + return; + }; + let Some((signature, direction)) = decode_gemini_tool_signature_carrier(carrier) else { + return; + }; + if !self + .seen_tool_signature_carriers + .insert(Self::output_item_key(item)) + { + return; + } + match direction { + GeminiToolSignatureCarrierDirection::Next => { + self.pending_tool_signatures.push_back(signature); + } + GeminiToolSignatureCarrierDirection::Previous => { + let Some(index) = self.last_tool_index else { + return; + }; + self.ensure_started(report_context, out); + let (id, model) = self.identity(report_context); + out.push(CanonicalStreamFrame { + id, + model, + event: CanonicalStreamEvent::ToolCallSignature { index, signature }, + }); + } + } + } + + fn emit_pending_tool_signature( + &mut self, + report_context: &Value, + out: &mut Vec, + index: usize, + ) { + let Some(signature) = self.pending_tool_signatures.pop_front() else { + return; + }; + self.ensure_started(report_context, out); + let (id, model) = self.identity(report_context); + out.push(CanonicalStreamFrame { + id, + model, + event: CanonicalStreamEvent::ToolCallSignature { index, signature }, + }); + } + fn emit_reasoning_item( &mut self, report_context: &Value, @@ -1336,7 +1402,14 @@ impl OpenAIResponsesProviderState { output_index: Option, final_item: bool, ) -> bool { - match item.get("type").and_then(Value::as_str).unwrap_or_default() { + let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default(); + if item_type == "reasoning" { + // Aether carries Gemini function-call signatures through Responses as + // encrypted reasoning items. Recover the carrier before the following + // function item is emitted so Gemini clients can replay it verbatim. + self.capture_tool_signature_carrier(report_context, out, item); + } + match item_type { "function_call" => { self.emit_tool_call_item(report_context, out, item, output_index); true @@ -3931,6 +4004,7 @@ fn openai_responses_incomplete_finish_reason(payload: &Value) -> String { mod tests { use super::*; use crate::formats::claude::messages::stream::ClaudeClientEmitter; + use crate::formats::gemini::generate_content::stream::GeminiClientEmitter; use crate::formats::openai::responses::encode_gemini_tool_signature_carrier; fn data_line(value: Value) -> Vec { @@ -5394,6 +5468,100 @@ mod tests { assert_eq!(text, "First message.Second message."); } + #[test] + fn openai_responses_provider_state_replays_gemini_signature_carrier_to_client() { + let mut state = OpenAIResponsesProviderState::default(); + let report_context = json!({}); + let signature = "skip_thought_signature_validator"; + let carrier = encode_gemini_tool_signature_carrier(signature) + .expect("signature carrier should encode"); + let reasoning_item = json!({ + "type": "reasoning", + "id": "rs_signature_0", + "status": "completed", + "encrypted_content": carrier, + "summary": [] + }); + let events = [ + json!({ + "type": "response.output_item.added", + "response_id": "resp_signed_fallback", + "output_index": 0, + "item": reasoning_item + }), + json!({ + "type": "response.output_item.done", + "response_id": "resp_signed_fallback", + "output_index": 0, + "item": reasoning_item + }), + json!({ + "type": "response.output_item.added", + "response_id": "resp_signed_fallback", + "output_index": 1, + "item": { + "type": "function_call", + "id": "fc_signed_1", + "call_id": "call_signed_1", + "name": "fabric_exec", + "arguments": "{\"code\":\"return 1\"}", + "status": "completed" + } + }), + ]; + let mut frames = Vec::new(); + for event in events { + frames.extend( + state + .push_line(&report_context, data_line(event)) + .expect("Responses event should parse"), + ); + } + + let signature_index = frames + .iter() + .position(|frame| { + matches!( + frame.event, + CanonicalStreamEvent::ToolCallSignature { + index: 1, + ref signature + } if signature == "skip_thought_signature_validator" + ) + }) + .expect("signature event should be restored"); + let call_index = frames + .iter() + .position(|frame| { + matches!( + frame.event, + CanonicalStreamEvent::ToolCallStart { index: 1, .. } + ) + }) + .expect("tool call should be emitted"); + assert!(signature_index < call_index); + assert_eq!( + frames + .iter() + .filter(|frame| matches!( + frame.event, + CanonicalStreamEvent::ToolCallSignature { .. } + )) + .count(), + 1, + "added and done snapshots must not duplicate the signature" + ); + + let mut emitter = GeminiClientEmitter::default(); + let mut bytes = Vec::new(); + for frame in frames { + bytes.extend(emitter.emit(frame).expect("Gemini frame should encode")); + } + let sse = String::from_utf8(bytes).expect("Gemini SSE should be UTF-8"); + assert!(sse.contains("\"name\":\"fabric_exec\"")); + assert!(sse.contains("\"thoughtSignature\":\"skip_thought_signature_validator\"")); + } + #[test] fn openai_responses_provider_state_delays_arguments_until_tool_name_is_known() { let mut state = OpenAIResponsesProviderState::default(); From f960bbd2c8307cd56a72c04e06b8b3de126301d0 Mon Sep 17 00:00:00 2001 From: AAEE86 Date: Mon, 21 Sep 2026 09:51:33 +0800 Subject: [PATCH 26/72] feat: expose upstream response model in usage records --- .../handlers/public/support/user_me_usage.rs | 21 ++++ .../aether-admin/src/observability/usage.rs | 28 +++++ .../list_recent_usage_audits_prefix.sql | 3 + .../queries/list_usage_audits_prefix.sql | 3 + .../adapters/postgres/src/usage/tests.rs | 2 + .../src/repository/usage/metadata_policy.rs | 30 ++++- .../contracts/src/repository/usage/mod.rs | 19 +-- .../contracts/src/repository/usage/types.rs | 103 +++++++++++++++- crates/aether-usage/runtime/src/event_wire.rs | 13 +- crates/aether-usage/runtime/src/record.rs | 14 ++- .../runtime/src/request_metadata.rs | 116 +++++++++++++++++- crates/aether-usage/runtime/src/runtime.rs | 16 ++- crates/aether-usage/runtime/src/write.rs | 28 ++++- frontend/src/api/dashboard.ts | 1 + frontend/src/api/me.ts | 3 + frontend/src/api/usage.ts | 3 + frontend/src/api/usageRecords.ts | 1 + .../usage/components/RequestDetailDrawer.vue | 8 +- .../usage/components/UsageModelDisplay.vue | 42 +++++-- .../usage/components/UsageRecordsTable.vue | 27 ++-- .../RequestDetailDrawer.pricing.spec.ts | 17 ++- .../__tests__/UsageRecordsTable.spec.ts | 33 ++++- .../usage/composables/useUsageData.ts | 8 +- frontend/src/views/shared/Usage.vue | 10 ++ 24 files changed, 466 insertions(+), 83 deletions(-) diff --git a/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs b/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs index 5052254bc..9ab057b8d 100644 --- a/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs +++ b/apps/aether-gateway/src/handlers/public/support/user_me_usage.rs @@ -567,6 +567,7 @@ fn build_users_me_usage_record_payload( "id": item.id, "model": item.model, "target_model": serde_json::Value::Null, + "response_model": item.provider_response_model(), "api_format": item.api_format, "endpoint_api_format": item.endpoint_api_format, "has_format_conversion": item.has_format_conversion, @@ -681,6 +682,7 @@ fn build_users_me_usage_active_payload(item: &StoredRequestUsageAudit) -> serde_ "client_ip": users_me_usage_metadata_string(item, "client_ip"), "user_agent": users_me_usage_metadata_string(item, "user_agent"), "target_model": item.target_model, + "response_model": item.provider_response_model(), "has_fallback": item.has_fallback(), }); payload["end_to_end_time_ms"] = json!(users_me_usage_metadata_u64(item, "end_to_end_time_ms")); @@ -1861,6 +1863,25 @@ mod tests { assert_eq!(payload["cache_creation_ephemeral_1h_input_tokens"], 6); } + #[test] + fn user_usage_payloads_expose_response_model_separately_from_mapping() { + let item = StoredRequestUsageAudit { + target_model: Some("provider-mapped-model".to_string()), + request_metadata: Some(json!({ + "provider_response_model": "gpt-5.1" + })), + ..sample_usage("completed") + }; + + let record = build_users_me_usage_record_payload(&item, false, &BTreeMap::new(), false); + let active = build_users_me_usage_active_payload(&item); + + for payload in [&record, &active] { + assert_eq!(payload["target_model"], "provider-mapped-model"); + assert_eq!(payload["response_model"], "gpt-5.1"); + } + } + #[test] fn user_usage_payloads_project_end_to_end_timings_from_metadata() { let item = StoredRequestUsageAudit { diff --git a/crates/aether-admin/src/observability/usage.rs b/crates/aether-admin/src/observability/usage.rs index 8a604590f..a1cadfef4 100644 --- a/crates/aether-admin/src/observability/usage.rs +++ b/crates/aether-admin/src/observability/usage.rs @@ -1349,6 +1349,7 @@ fn admin_usage_active_request_json( if let Some(target_model) = item.target_model.as_ref() { value["target_model"] = json!(target_model); } + value["response_model"] = json!(item.provider_response_model()); if let Some(reasoning_effort) = item.provider_reasoning_effort() { value["reasoning_effort"] = json!(reasoning_effort); } @@ -1408,6 +1409,7 @@ pub fn admin_usage_record_json( "provider": item.provider_name, "model": item.model, "target_model": item.target_model, + "response_model": item.provider_response_model(), "input_tokens": item.input_tokens, "effective_input_tokens": admin_usage_effective_input_tokens(item), "output_tokens": item.output_tokens, @@ -2842,6 +2844,32 @@ mod tests { assert_eq!(record["client_is_stream"], false); } + #[test] + fn admin_usage_payloads_expose_response_model_separately_from_mapping() { + let item = StoredRequestUsageAudit { + target_model: Some("provider-mapped-model".to_string()), + request_metadata: Some(json!({ + "provider_response_model": "gpt-5.1" + })), + ..sample_usage("completed", Some(200), None) + }; + + let record = admin_usage_record_json( + &item, + &BTreeMap::new(), + &BTreeMap::new(), + false, + false, + None, + ); + let active = admin_usage_active_request_json(&item, None, None, None); + + for payload in [&record, &active] { + assert_eq!(payload["target_model"], "provider-mapped-model"); + assert_eq!(payload["response_model"], "gpt-5.1"); + } + } + #[test] fn admin_usage_payloads_project_end_to_end_timings_from_metadata() { let item = StoredRequestUsageAudit { diff --git a/crates/aether-data/adapters/postgres/src/usage/queries/list_recent_usage_audits_prefix.sql b/crates/aether-data/adapters/postgres/src/usage/queries/list_recent_usage_audits_prefix.sql index d0909bcd2..d6d8a736d 100644 --- a/crates/aether-data/adapters/postgres/src/usage/queries/list_recent_usage_audits_prefix.sql +++ b/crates/aether-data/adapters/postgres/src/usage/queries/list_recent_usage_audits_prefix.sql @@ -183,6 +183,7 @@ SELECT OR NULLIF(BTRIM("usage".request_metadata->>'provider_reasoning_effort'), '') IS NOT NULL OR NULLIF(BTRIM("usage".request_metadata->>'provider_service_tier'), '') IS NOT NULL OR NULLIF(BTRIM("usage".request_metadata->>'provider_actual_service_tier'), '') IS NOT NULL + OR NULLIF(BTRIM("usage".request_metadata->>'provider_response_model'), '') IS NOT NULL OR ("usage".request_metadata->>'client_requested_stream') IN ('true', 'false') OR ("usage".request_metadata->>'upstream_is_stream') IN ('true', 'false') OR ("usage".request_metadata->>'websocket_mode') IN ('true', 'false') @@ -208,6 +209,8 @@ SELECT NULLIF(BTRIM("usage".request_metadata->>'provider_service_tier'), ''), 'provider_actual_service_tier', NULLIF(BTRIM("usage".request_metadata->>'provider_actual_service_tier'), ''), + 'provider_response_model', + NULLIF(BTRIM("usage".request_metadata->>'provider_response_model'), ''), 'client_requested_stream', CASE WHEN ("usage".request_metadata->>'client_requested_stream') IN ('true', 'false') diff --git a/crates/aether-data/adapters/postgres/src/usage/queries/list_usage_audits_prefix.sql b/crates/aether-data/adapters/postgres/src/usage/queries/list_usage_audits_prefix.sql index d0909bcd2..d6d8a736d 100644 --- a/crates/aether-data/adapters/postgres/src/usage/queries/list_usage_audits_prefix.sql +++ b/crates/aether-data/adapters/postgres/src/usage/queries/list_usage_audits_prefix.sql @@ -183,6 +183,7 @@ SELECT OR NULLIF(BTRIM("usage".request_metadata->>'provider_reasoning_effort'), '') IS NOT NULL OR NULLIF(BTRIM("usage".request_metadata->>'provider_service_tier'), '') IS NOT NULL OR NULLIF(BTRIM("usage".request_metadata->>'provider_actual_service_tier'), '') IS NOT NULL + OR NULLIF(BTRIM("usage".request_metadata->>'provider_response_model'), '') IS NOT NULL OR ("usage".request_metadata->>'client_requested_stream') IN ('true', 'false') OR ("usage".request_metadata->>'upstream_is_stream') IN ('true', 'false') OR ("usage".request_metadata->>'websocket_mode') IN ('true', 'false') @@ -208,6 +209,8 @@ SELECT NULLIF(BTRIM("usage".request_metadata->>'provider_service_tier'), ''), 'provider_actual_service_tier', NULLIF(BTRIM("usage".request_metadata->>'provider_actual_service_tier'), ''), + 'provider_response_model', + NULLIF(BTRIM("usage".request_metadata->>'provider_response_model'), ''), 'client_requested_stream', CASE WHEN ("usage".request_metadata->>'client_requested_stream') IN ('true', 'false') diff --git a/crates/aether-data/adapters/postgres/src/usage/tests.rs b/crates/aether-data/adapters/postgres/src/usage/tests.rs index ee140363b..33a1b01e3 100644 --- a/crates/aether-data/adapters/postgres/src/usage/tests.rs +++ b/crates/aether-data/adapters/postgres/src/usage/tests.rs @@ -3611,6 +3611,8 @@ fn usage_sql_uses_json_null_placeholders_for_usage_payload_columns() { assert!(sql.contains("request_metadata->>'provider_reasoning_effort'")); assert!(sql.contains("request_metadata->>'provider_service_tier'")); assert!(sql.contains("request_metadata->>'provider_actual_service_tier'")); + assert!(sql.contains("request_metadata->>'provider_response_model'")); + assert!(sql.contains("'provider_response_model'")); assert!(sql.contains("request_metadata->>'websocket_mode'")); assert!(sql.contains("'websocket_mode'")); assert!(sql.contains("AS client_family")); diff --git a/crates/aether-data/contracts/src/repository/usage/metadata_policy.rs b/crates/aether-data/contracts/src/repository/usage/metadata_policy.rs index 70b2fdca2..a559c2b36 100644 --- a/crates/aether-data/contracts/src/repository/usage/metadata_policy.rs +++ b/crates/aether-data/contracts/src/repository/usage/metadata_policy.rs @@ -8,9 +8,10 @@ use serde_json::{Map, Value}; use crate::repository::candidates::sanitize_request_candidate_skip_reason; use super::{ - LIVE_SESSION_METADATA_KEY, PLAN_USAGE_RESERVATION_DEFERRED_METADATA_KEY, - PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, - PROVIDER_REASONING_EFFORT_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY, + normalize_provider_response_model, LIVE_SESSION_METADATA_KEY, + PLAN_USAGE_RESERVATION_DEFERRED_METADATA_KEY, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, + PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, PROVIDER_REASONING_EFFORT_METADATA_KEY, + PROVIDER_RESPONSE_MODEL_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY, REALTIME_SESSION_METADATA_KEY, REQUESTED_REASONING_EFFORT_METADATA_KEY, ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY, USAGE_AVAILABLE_METADATA_KEY, USAGE_PRICING_AVAILABLE_METADATA_KEY, @@ -94,6 +95,12 @@ pub fn sanitize_usage_request_metadata_object(source: &Map) -> Op ] { insert_known_string(source, &mut target, key, sanitize_service_tier); } + insert_known_string( + source, + &mut target, + PROVIDER_RESPONSE_MODEL_METADATA_KEY, + normalize_provider_response_model, + ); insert_bounded_u64( source, &mut target, @@ -1279,6 +1286,23 @@ mod tests { } } + #[test] + fn persistence_projection_keeps_bounded_response_model_only_as_a_string() { + let metadata = sanitize_usage_request_metadata(Some(json!({ + "provider_response_model": " GPT-5.1 " + }))) + .expect("response model should remain"); + assert_eq!(metadata["provider_response_model"], "GPT-5.1"); + assert!(sanitize_usage_request_metadata(Some(json!({ + "provider_response_model": 42 + }))) + .is_none()); + assert!(sanitize_usage_request_metadata(Some(json!({ + "provider_response_model": "x".repeat(257) + }))) + .is_none()); + } + #[test] fn persistence_projection_keeps_only_bounded_settlement_facts() { let metadata = sanitize_usage_request_metadata(Some(json!({ diff --git a/crates/aether-data/contracts/src/repository/usage/mod.rs b/crates/aether-data/contracts/src/repository/usage/mod.rs index ed2377a3a..b205badf4 100644 --- a/crates/aether-data/contracts/src/repository/usage/mod.rs +++ b/crates/aether-data/contracts/src/repository/usage/mod.rs @@ -15,11 +15,12 @@ pub use policy::*; pub use types::{ canonical_usage_body_ref_for, extract_provider_actual_service_tier_from_response, extract_provider_cache_ttl_minutes_from_metadata, extract_provider_reasoning_effort_from_body, - extract_provider_service_tier_from_body, normalize_provider_service_tier, parse_usage_body_ref, + extract_provider_response_model_from_bodies, extract_provider_service_tier_from_body, + normalize_provider_response_model, normalize_provider_service_tier, parse_usage_body_ref, resolve_provider_cache_ttl_minutes, resolve_provider_service_tier_from_request_capture, - usage_body_ref, usage_request_metadata_client_family, ApiKeyLastUsedDelta, - ManagementTokenCounterDelta, PendingUsageCleanupSummary, ProviderApiKeyWindowUsageRequest, - ProxyNodeCounterDelta, StoredProviderApiKeyUsageSummary, + usage_body_capture_is_authoritative, usage_body_ref, usage_request_metadata_client_family, + ApiKeyLastUsedDelta, ManagementTokenCounterDelta, PendingUsageCleanupSummary, + ProviderApiKeyWindowUsageRequest, ProxyNodeCounterDelta, StoredProviderApiKeyUsageSummary, StoredProviderApiKeyWindowUsageSummary, StoredProviderUsageSummary, StoredProviderUsageWindow, StoredRequestUsageAudit, StoredUsageAuditAggregation, StoredUsageAuditSummary, StoredUsageBodyPayload, StoredUsageBreakdownSummaryRow, StoredUsageCacheAffinityHitSummary, @@ -46,9 +47,9 @@ pub use types::{ UsageTimeSeriesQuery, UsageWriteRepository, LIVE_SESSION_METADATA_KEY, PLAN_USAGE_RESERVATION_DEFERRED_METADATA_KEY, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, PROVIDER_REASONING_EFFORT_METADATA_KEY, - PROVIDER_SERVICE_TIER_METADATA_KEY, REALTIME_SESSION_METADATA_KEY, - REQUESTED_REASONING_EFFORT_METADATA_KEY, ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, - ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY, USAGE_AVAILABLE_METADATA_KEY, - USAGE_PRICING_AVAILABLE_METADATA_KEY, WEBSOCKET_MODE_METADATA_KEY, - WEBSOCKET_TRANSPORT_METADATA_KEY, + PROVIDER_RESPONSE_MODEL_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY, + REALTIME_SESSION_METADATA_KEY, REQUESTED_REASONING_EFFORT_METADATA_KEY, + ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY, ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY, + USAGE_AVAILABLE_METADATA_KEY, USAGE_PRICING_AVAILABLE_METADATA_KEY, + WEBSOCKET_MODE_METADATA_KEY, WEBSOCKET_TRANSPORT_METADATA_KEY, }; diff --git a/crates/aether-data/contracts/src/repository/usage/types.rs b/crates/aether-data/contracts/src/repository/usage/types.rs index c266625e8..7382b6867 100644 --- a/crates/aether-data/contracts/src/repository/usage/types.rs +++ b/crates/aether-data/contracts/src/repository/usage/types.rs @@ -6,6 +6,7 @@ pub const PROVIDER_REASONING_EFFORT_METADATA_KEY: &str = "provider_reasoning_eff pub const REQUESTED_REASONING_EFFORT_METADATA_KEY: &str = "requested_reasoning_effort"; pub const PROVIDER_SERVICE_TIER_METADATA_KEY: &str = "provider_service_tier"; pub const PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY: &str = "provider_actual_service_tier"; +pub const PROVIDER_RESPONSE_MODEL_METADATA_KEY: &str = "provider_response_model"; pub const PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY: &str = "provider_cache_ttl_minutes"; pub const ROUTING_CANDIDATE_SKIP_REASON_METADATA_KEY: &str = "routing_candidate_skip_reason"; pub const ROUTING_FAILURE_DIAGNOSTIC_METADATA_KEY: &str = "routing_failure_diagnostic"; @@ -119,6 +120,42 @@ pub fn normalize_provider_service_tier(value: &str) -> Option { Some(value.to_ascii_lowercase()) } +/// 清洗响应体顶层 `model`,保留大小写,只去除首尾空白。 +pub fn normalize_provider_response_model(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() || value.len() > 256 { + return None; + } + Some(value.to_string()) +} + +/// 只有请求体和响应体都可作为完整事实时,才计算响应模型,避免用截断内容猜测。 +pub fn extract_provider_response_model_from_bodies( + request_body: Option<&Value>, + request_body_state: Option, + response_body: Option<&Value>, + response_body_state: Option, +) -> Option { + if !usage_body_capture_is_authoritative(request_body, request_body_state) + || !usage_body_capture_is_authoritative(response_body, response_body_state) + { + return None; + } + + let request_model = request_body + .and_then(Value::as_object) + .and_then(|body| body.get("model")) + .and_then(Value::as_str) + .and_then(normalize_provider_response_model)?; + let response_model = response_body + .and_then(Value::as_object) + .and_then(|body| body.get("model")) + .and_then(Value::as_str) + .and_then(normalize_provider_response_model)?; + + (request_model != response_model).then_some(response_model) +} + /// Resolves a provider processing tier exclusively from the final upstream request. /// /// A complete captured body is authoritative, including when it contains no tier. The metadata @@ -129,7 +166,7 @@ pub fn resolve_provider_service_tier_from_request_capture( provider_request_body_state: Option, request_metadata: Option<&Value>, ) -> Option { - if request_body_capture_is_authoritative(provider_request_body, provider_request_body_state) { + if usage_body_capture_is_authoritative(provider_request_body, provider_request_body_state) { return extract_provider_service_tier_from_body(provider_request_body); } @@ -153,7 +190,7 @@ pub fn resolve_provider_service_tier_from_request_capture( .and_then(normalize_provider_service_tier) } -fn request_body_capture_is_authoritative( +pub fn usage_body_capture_is_authoritative( request_body: Option<&Value>, request_body_state: Option, ) -> bool { @@ -184,7 +221,7 @@ fn resolve_reasoning_effort_from_request_capture( request_metadata: Option<&Value>, metadata_key: &str, ) -> Option { - if request_body_capture_is_authoritative(request_body, request_body_state) { + if usage_body_capture_is_authoritative(request_body, request_body_state) { return extract_provider_reasoning_effort_from_body(request_body); } @@ -701,6 +738,11 @@ impl StoredRequestUsageAudit { }) } + pub fn provider_response_model(&self) -> Option { + self.request_metadata_string(PROVIDER_RESPONSE_MODEL_METADATA_KEY) + .and_then(normalize_provider_response_model) + } + pub fn provider_cache_ttl_minutes(&self) -> Option { resolve_provider_cache_ttl_minutes( self.endpoint_api_format @@ -2646,7 +2688,8 @@ fn parse_timestamp(value: i64, field_name: &str) -> Result {} } metadata = attach_provider_response_body_metadata(metadata, data.response_body.as_ref()); + metadata = attach_provider_response_model_metadata( + metadata, + request_body, + data.request_body_state, + data.response_body.as_ref(), + data.response_body_state, + ); // Billing reads raw-body TTL before metadata regardless of capture state. // Preserve that precedence independently of reasoning and tier authority. if let Some(cache_ttl) = body_cache_ttl { diff --git a/crates/aether-usage/runtime/src/record.rs b/crates/aether-usage/runtime/src/record.rs index ac46cc482..8433168ab 100644 --- a/crates/aether-usage/runtime/src/record.rs +++ b/crates/aether-usage/runtime/src/record.rs @@ -5,9 +5,9 @@ use aether_data_contracts::DataLayerError; use crate::request_metadata::{ attach_client_request_body_metadata, attach_provider_request_body_metadata, - clear_client_request_body_metadata, clear_provider_request_body_metadata, - request_body_derived_facts_action, sanitize_usage_request_metadata, - RequestBodyDerivedFactsAction, + attach_provider_response_model_metadata, clear_client_request_body_metadata, + clear_provider_request_body_metadata, request_body_derived_facts_action, + sanitize_usage_request_metadata, RequestBodyDerivedFactsAction, }; use crate::{UsageEvent, UsageEventType}; @@ -85,6 +85,14 @@ pub fn build_upsert_usage_record_from_event( } RequestBodyDerivedFactsAction::Preserve => {} } + // 响应模型必须在 body 被裁剪前从客户端请求体和上游响应体共同派生;缺少权威 body 时保留已派生事实。 + data.request_metadata = attach_provider_response_model_metadata( + data.request_metadata, + data.request_body.as_ref(), + data.request_body_state, + data.response_body.as_ref(), + data.response_body_state, + ); let now_unix_secs = event.timestamp_ms / 1_000; Ok(UpsertUsageRecord { diff --git a/crates/aether-usage/runtime/src/request_metadata.rs b/crates/aether-usage/runtime/src/request_metadata.rs index 33f7b5e0f..925e60999 100644 --- a/crates/aether-usage/runtime/src/request_metadata.rs +++ b/crates/aether-usage/runtime/src/request_metadata.rs @@ -1,13 +1,15 @@ use aether_contracts::ExecutionPlan; use aether_data_contracts::repository::usage::{ extract_provider_actual_service_tier_from_response, - extract_provider_reasoning_effort_from_body, extract_provider_service_tier_from_body, - normalize_provider_service_tier, resolve_provider_cache_ttl_minutes, + extract_provider_reasoning_effort_from_body, extract_provider_response_model_from_bodies, + extract_provider_service_tier_from_body, normalize_provider_service_tier, + resolve_provider_cache_ttl_minutes, sanitize_usage_request_metadata as project_usage_request_metadata, sanitize_usage_request_metadata_object as project_usage_request_metadata_object, sanitize_usage_request_metadata_ref as project_usage_request_metadata_ref, - UsageBodyCaptureState, PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, - PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, PROVIDER_REASONING_EFFORT_METADATA_KEY, + usage_body_capture_is_authoritative, UsageBodyCaptureState, + PROVIDER_ACTUAL_SERVICE_TIER_METADATA_KEY, PROVIDER_CACHE_TTL_MINUTES_METADATA_KEY, + PROVIDER_REASONING_EFFORT_METADATA_KEY, PROVIDER_RESPONSE_MODEL_METADATA_KEY, PROVIDER_SERVICE_TIER_METADATA_KEY, REQUESTED_REASONING_EFFORT_METADATA_KEY, }; use serde_json::{Map, Value}; @@ -249,6 +251,75 @@ pub(crate) fn attach_provider_response_body_metadata( attach_provider_actual_service_tier_metadata(metadata, actual_service_tier.as_deref()) } +pub(crate) fn attach_provider_response_model_metadata( + metadata: Option, + request_body: Option<&Value>, + request_body_state: Option, + response_body: Option<&Value>, + response_body_state: Option, +) -> Option { + let both_bodies_are_authoritative = + usage_body_capture_is_authoritative(request_body, request_body_state) + && usage_body_capture_is_authoritative(response_body, response_body_state); + let response_model = extract_provider_response_model_from_bodies( + request_body, + request_body_state, + response_body, + response_body_state, + ); + if !both_bodies_are_authoritative && response_model.is_none() { + return metadata; + } + + let mut object = match metadata { + Some(Value::Object(object)) => object, + _ => Map::new(), + }; + // 完整终态 body 是最终候选的权威事实;相同、无效或缺失模型都要清除旧候选值。 + if both_bodies_are_authoritative { + object.remove(PROVIDER_RESPONSE_MODEL_METADATA_KEY); + } + if let Some(response_model) = response_model { + object.insert( + PROVIDER_RESPONSE_MODEL_METADATA_KEY.to_string(), + Value::String(response_model), + ); + } + (!object.is_empty()).then_some(Value::Object(object)) +} + +/// 终态候选无法完成比较时,显式清除旧响应模型,避免重试/故障转移残留。 +pub(crate) fn refresh_provider_response_model_metadata( + metadata: Option, + request_body: Option<&Value>, + request_body_state: Option, + response_body: Option<&Value>, + response_body_state: Option, +) -> Option { + let mut object = match metadata { + Some(Value::Object(object)) => object, + _ => Map::new(), + }; + object.remove(PROVIDER_RESPONSE_MODEL_METADATA_KEY); + + if usage_body_capture_is_authoritative(request_body, request_body_state) + && usage_body_capture_is_authoritative(response_body, response_body_state) + { + if let Some(response_model) = extract_provider_response_model_from_bodies( + request_body, + request_body_state, + response_body, + response_body_state, + ) { + object.insert( + PROVIDER_RESPONSE_MODEL_METADATA_KEY.to_string(), + Value::String(response_model), + ); + } + } + (!object.is_empty()).then_some(Value::Object(object)) +} + /// Refreshes the response-derived tier for a terminal snapshot. Complete response objects are /// authoritative even when they contain no tier (which clears a stale candidate value). Capture /// placeholders/absent bodies are not authoritative, so a terminal summary already present in @@ -323,8 +394,9 @@ mod tests { use super::{ attach_client_request_body_metadata, attach_provider_actual_service_tier_metadata, attach_provider_request_body_metadata, attach_provider_response_body_metadata, - build_usage_request_metadata_seed, merge_usage_request_metadata, - merge_usage_request_metadata_owned, refresh_provider_response_body_metadata, + attach_provider_response_model_metadata, build_usage_request_metadata_seed, + merge_usage_request_metadata, merge_usage_request_metadata_owned, + refresh_provider_response_body_metadata, refresh_provider_response_model_metadata, retain_first_byte_request_metadata, sanitize_usage_request_metadata, sanitize_usage_request_metadata_ref, }; @@ -802,6 +874,38 @@ mod tests { ); } + #[test] + fn response_model_metadata_is_independent_from_mapping_and_clears_stale_values() { + let metadata = attach_provider_response_model_metadata( + Some(json!({"provider_response_model": "old-model", "trace_id": "trace-1"})), + Some(&json!({"model": "gpt-5"})), + Some(UsageBodyCaptureState::Inline), + Some(&json!({"model": "gpt-5.1"})), + Some(UsageBodyCaptureState::Inline), + ) + .expect("response model should be attached"); + assert_eq!(metadata["provider_response_model"], "gpt-5.1"); + assert_eq!(metadata["trace_id"], "trace-1"); + + let metadata = refresh_provider_response_model_metadata( + Some(json!({"provider_response_model": "gpt-5.1"})), + Some(&json!({"model": "gpt-5"})), + Some(UsageBodyCaptureState::Inline), + Some(&json!({"model": "gpt-5"})), + Some(UsageBodyCaptureState::Inline), + ); + assert!(metadata.is_none()); + + let metadata = refresh_provider_response_model_metadata( + Some(json!({"provider_response_model": "gpt-5.1"})), + None, + Some(UsageBodyCaptureState::Disabled), + Some(&json!({"model": "gpt-5.2"})), + Some(UsageBodyCaptureState::Inline), + ); + assert!(metadata.is_none()); + } + #[test] fn terminal_response_refresh_replaces_stale_actual_tier() { let metadata = refresh_provider_response_body_metadata( diff --git a/crates/aether-usage/runtime/src/runtime.rs b/crates/aether-usage/runtime/src/runtime.rs index 99706f638..9b1152f95 100644 --- a/crates/aether-usage/runtime/src/runtime.rs +++ b/crates/aether-usage/runtime/src/runtime.rs @@ -21,9 +21,10 @@ use crate::executor::spawn_on_usage_background_runtime; use crate::queue::is_permanent_enqueue_error; use crate::request_metadata::{ attach_client_request_body_metadata, attach_provider_request_body_metadata, - attach_provider_response_body_metadata, clear_client_request_body_metadata, - clear_provider_request_body_metadata, request_body_derived_facts_action, - retain_first_byte_request_metadata, RequestBodyDerivedFactsAction, + attach_provider_response_body_metadata, attach_provider_response_model_metadata, + clear_client_request_body_metadata, clear_provider_request_body_metadata, + request_body_derived_facts_action, retain_first_byte_request_metadata, + RequestBodyDerivedFactsAction, }; use crate::settlement::{ reconcile_usage_policy_cost_for_event_with_result, settle_usage_with_reconciled_cost, @@ -5297,8 +5298,15 @@ fn preserve_request_facts_with_legacy_missing( fn preserve_provider_response_facts(event: &mut UsageEvent) { let metadata = event.data.request_metadata.take(); - event.data.request_metadata = + let metadata = attach_provider_response_body_metadata(metadata, event.data.response_body.as_ref()); + event.data.request_metadata = attach_provider_response_model_metadata( + metadata, + event.data.request_body.as_ref(), + event.data.request_body_state, + event.data.response_body.as_ref(), + event.data.response_body_state, + ); } impl UsageQueueHealthSnapshot { diff --git a/crates/aether-usage/runtime/src/write.rs b/crates/aether-usage/runtime/src/write.rs index 0ec9e393e..dc193c0c3 100644 --- a/crates/aether-usage/runtime/src/write.rs +++ b/crates/aether-usage/runtime/src/write.rs @@ -17,9 +17,10 @@ use crate::body_capture::{ }; use crate::request_metadata::{ attach_client_request_body_metadata, attach_provider_actual_service_tier_metadata, - attach_provider_request_body_metadata, build_usage_request_metadata_seed, - merge_usage_request_metadata, merge_usage_request_metadata_owned, - refresh_provider_response_body_metadata, sanitize_usage_request_metadata, + attach_provider_request_body_metadata, attach_provider_response_model_metadata, + build_usage_request_metadata_seed, merge_usage_request_metadata, + merge_usage_request_metadata_owned, refresh_provider_response_body_metadata, + refresh_provider_response_model_metadata, sanitize_usage_request_metadata, sanitize_usage_request_metadata_ref, }; use crate::{ @@ -724,6 +725,13 @@ fn build_terminal_usage_event_from_seed_impl( Some(model.as_str()), provider_request.as_ref(), ); + let request_metadata = attach_provider_response_model_metadata( + request_metadata, + request_body.as_ref(), + body_states.request_body_state, + provider_response.as_ref(), + body_states.response_body_state, + ); let mut data = UsageEventData { user_id, @@ -1047,6 +1055,13 @@ pub fn build_sync_terminal_usage_seed( context_seed.request_metadata, provider_response_full.as_ref(), ); + let request_metadata = refresh_provider_response_model_metadata( + request_metadata, + context_seed.request_body.as_ref(), + context_seed.body_states.request_body_state, + provider_response_full.as_ref(), + provider_response_body_state, + ); TerminalUsageSeed { terminal_state, @@ -1225,6 +1240,13 @@ pub fn build_stream_terminal_usage_seed( context_seed.request_metadata, provider_response_full.as_ref(), ); + let request_metadata = refresh_provider_response_model_metadata( + request_metadata, + context_seed.request_body.as_ref(), + context_seed.body_states.request_body_state, + provider_response_full.as_ref(), + provider_response_body_state, + ); // The parser's terminal summary is authoritative when a response body is truncated or the // body and summary disagree; attach it after the body refresh so it wins. let request_metadata = attach_provider_actual_service_tier_metadata( diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index 44e6867eb..4d36ba1f8 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -219,6 +219,7 @@ export interface RequestDetail { has_format_conversion?: boolean | null model: string target_model?: string | null // 映射后的目标模型名 + response_model?: string | null // 上游响应体实际返回的模型名 requested_reasoning_effort?: string | null reasoning_effort?: string | null service_tier?: string | null diff --git a/frontend/src/api/me.ts b/frontend/src/api/me.ts index a816c9875..b1509c05f 100644 --- a/frontend/src/api/me.ts +++ b/frontend/src/api/me.ts @@ -60,6 +60,7 @@ export interface UsageRecordDetail { reasoning_effort?: string | null service_tier?: string | null actual_service_tier?: string | null + response_model?: string | null input_tokens: number effective_input_tokens?: number output_tokens: number @@ -392,6 +393,7 @@ export const meApi = { has_format_conversion?: boolean | null has_fallback?: boolean | null target_model?: string | null + response_model?: string | null request_type?: string | null requested_reasoning_effort?: string | null reasoning_effort?: string | null @@ -438,6 +440,7 @@ export const meApi = { has_format_conversion?: boolean | null has_fallback?: boolean | null target_model?: string | null + response_model?: string | null request_type?: string | null requested_reasoning_effort?: string | null reasoning_effort?: string | null diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index 88f67a499..23cab0b21 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -20,6 +20,7 @@ export interface UsageRecord { reasoning_effort?: string | null service_tier?: string | null actual_service_tier?: string | null + response_model?: string | null input_tokens: number effective_input_tokens?: number output_tokens: number @@ -618,6 +619,7 @@ export const usageApi = { has_format_conversion?: boolean | null has_fallback?: boolean | null target_model?: string | null + response_model?: string | null request_type?: string | null requested_reasoning_effort?: string | null reasoning_effort?: string | null @@ -685,6 +687,7 @@ export const usageApi = { has_format_conversion?: boolean | null has_fallback?: boolean | null target_model?: string | null + response_model?: string | null request_type?: string | null requested_reasoning_effort?: string | null reasoning_effort?: string | null diff --git a/frontend/src/api/usageRecords.ts b/frontend/src/api/usageRecords.ts index 8ef0b4150..d6814a2da 100644 --- a/frontend/src/api/usageRecords.ts +++ b/frontend/src/api/usageRecords.ts @@ -19,6 +19,7 @@ export interface UsageRecord { model: string target_model?: string | null // 映射后的目标模型名(若无映射则为空) model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段) + response_model?: string | null // 上游响应体实际返回的模型名 request_type?: string | null // 由请求语义识别出的操作类型 requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系 reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别 diff --git a/frontend/src/features/usage/components/RequestDetailDrawer.vue b/frontend/src/features/usage/components/RequestDetailDrawer.vue index f67ac97f6..14f142760 100644 --- a/frontend/src/features/usage/components/RequestDetailDrawer.vue +++ b/frontend/src/features/usage/components/RequestDetailDrawer.vue @@ -1016,6 +1016,7 @@ const emit = defineEmits<{ reasoningEffort?: string | null serviceTier?: string | null actualServiceTier?: string | null + responseModel?: string | null imageProgress?: ImageProgress | null errorMessage?: string | null updatedAt?: string | null @@ -1158,6 +1159,7 @@ type HeaderModelTextField = | 'model' | 'target_model' | 'model_version' + | 'response_model' | 'request_type' | 'requested_reasoning_effort' | 'reasoning_effort' @@ -1166,6 +1168,7 @@ type HeaderModelTextField = const FINAL_PROVIDER_HEADER_FIELDS = new Set([ 'target_model', + 'response_model', 'reasoning_effort', 'service_tier', 'actual_service_tier', @@ -1252,6 +1255,7 @@ watch( props.summaryRecord?.model, props.summaryRecord?.target_model, props.summaryRecord?.model_version, + props.summaryRecord?.response_model, props.summaryRecord?.request_type, props.summaryRecord?.requested_reasoning_effort, props.summaryRecord?.reasoning_effort, @@ -1298,6 +1302,7 @@ function emitDetailRequestState(nextDetail: RequestDetail) { const reasoningEffort = resolveHeaderModelTextField('reasoning_effort', nextDetail) const serviceTier = resolveHeaderModelTextField('service_tier', nextDetail) const actualServiceTier = resolveHeaderModelTextField('actual_service_tier', nextDetail) + const responseModel = resolveHeaderModelTextField('response_model', nextDetail) emit('requestState', { id, @@ -1334,6 +1339,7 @@ function emitDetailRequestState(nextDetail: RequestDetail) { ...(reasoningEffort ? { reasoningEffort } : {}), ...(serviceTier ? { serviceTier } : {}), ...(actualServiceTier ? { actualServiceTier } : {}), + ...(responseModel ? { responseModel } : {}), errorMessage: nextDetail.error_message ?? undefined, updatedAt: nextDetail.updated_at ?? undefined, }) @@ -1610,7 +1616,7 @@ const headerModelRecord = computed(() => { return { model: resolveHeaderModelTextField('model', currentDetail) ?? '-', target_model: resolveHeaderModelTextField('target_model', currentDetail), - model_version: resolveHeaderModelTextField('model_version', currentDetail), + response_model: resolveHeaderModelTextField('response_model', currentDetail), request_type: resolveHeaderModelTextField('request_type', currentDetail), requested_reasoning_effort: resolveHeaderModelTextField( 'requested_reasoning_effort', diff --git a/frontend/src/features/usage/components/UsageModelDisplay.vue b/frontend/src/features/usage/components/UsageModelDisplay.vue index 123d3094f..7f6bf2f25 100644 --- a/frontend/src/features/usage/components/UsageModelDisplay.vue +++ b/frontend/src/features/usage/components/UsageModelDisplay.vue @@ -9,18 +9,29 @@ >
{{ record.model }} -