From ef41e4da46ffe1ce122e7cec5bcc88c8be835114 Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Wed, 15 Apr 2026 11:28:56 +0800 Subject: [PATCH 1/9] fix(proxy): strip hop-by-hop response headers per RFC 7230 (#2060) --- src-tauri/src/proxy/handlers.rs | 8 +- src-tauri/src/proxy/response_processor.rs | 129 +++++++++++++++++++++- src-tauri/src/services/stream_check.rs | 3 +- 3 files changed, 130 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 3a2bd4ca4..c203cb8bb 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -20,7 +20,8 @@ use super::{ }, response_processor::{ create_logged_passthrough_stream, process_response, read_decoded_body, - strip_entity_headers_for_rebuilt_body, SseUsageCollector, + strip_entity_headers_for_rebuilt_body, strip_hop_by_hop_response_headers, + SseUsageCollector, }, server::ProxyState, types::*, @@ -216,10 +217,6 @@ async fn handle_claude_transform( "Cache-Control", axum::http::HeaderValue::from_static("no-cache"), ); - headers.insert( - "Connection", - axum::http::HeaderValue::from_static("keep-alive"), - ); let body = axum::body::Body::from_stream(logged_stream); return Ok((headers, body).into_response()); @@ -287,6 +284,7 @@ async fn handle_claude_transform( // 构建响应 let mut builder = axum::response::Response::builder().status(status); strip_entity_headers_for_rebuilt_body(&mut response_headers); + strip_hop_by_hop_response_headers(&mut response_headers); for (key, value) in response_headers.iter() { builder = builder.header(key, value); diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 1a185f93b..3008bb5fd 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -11,7 +11,7 @@ use super::{ usage::parser::TokenUsage, ProxyError, }; -use axum::http::header::HeaderMap; +use axum::http::{header::HeaderMap, HeaderName}; use axum::response::{IntoResponse, Response}; use bytes::Bytes; use futures::stream::{Stream, StreamExt}; @@ -68,6 +68,41 @@ fn get_content_encoding(headers: &HeaderMap) -> Option { .filter(|s| !s.is_empty() && s != "identity") } +/// RFC 2616 / RFC 7230 中定义的不应被代理继续转发的响应头。 +const HOP_BY_HOP_RESPONSE_HEADERS: &[&str] = &[ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "trailers", + "transfer-encoding", + "upgrade", +]; + +/// 移除响应侧 hop-by-hop 头,以及 `Connection` 中点名的扩展头。 +pub(crate) fn strip_hop_by_hop_response_headers(headers: &mut HeaderMap) { + let connection_listed_headers: Vec = headers + .get_all(axum::http::header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .filter(|name| !name.is_empty()) + .filter_map(|name| HeaderName::from_bytes(name.as_bytes()).ok()) + .collect(); + + for name in HOP_BY_HOP_RESPONSE_HEADERS { + headers.remove(*name); + } + + for name in connection_listed_headers { + headers.remove(name); + } +} + /// 移除在重建响应体后会失真的实体头。 pub(crate) fn strip_entity_headers_for_rebuilt_body(headers: &mut HeaderMap) { headers.remove(axum::http::header::CONTENT_ENCODING); @@ -163,10 +198,13 @@ pub async fn handle_streaming( ); } + let mut response_headers = response.headers().clone(); + strip_hop_by_hop_response_headers(&mut response_headers); + let mut builder = axum::response::Response::builder().status(status); // 复制响应头 - for (key, value) in response.headers() { + for (key, value) in &response_headers { builder = builder.header(key, value); } @@ -207,8 +245,9 @@ pub async fn handle_non_streaming( } else { Duration::ZERO }; - let (response_headers, status, body_bytes) = + let (mut response_headers, status, body_bytes) = read_decoded_body(response, ctx.tag, body_timeout).await?; + strip_hop_by_hop_response_headers(&mut response_headers); log::debug!( "[{}] 上游响应体内容: {}", @@ -715,6 +754,90 @@ mod tests { assert_eq!(super::strip_sse_field("id:1", "data"), None); } + #[test] + fn test_strip_hop_by_hop_response_headers_removes_standard_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::CONNECTION, + axum::http::HeaderValue::from_static("keep-alive"), + ); + headers.insert( + axum::http::header::HeaderName::from_static("keep-alive"), + axum::http::HeaderValue::from_static("timeout=5"), + ); + headers.insert( + axum::http::header::TRANSFER_ENCODING, + axum::http::HeaderValue::from_static("chunked"), + ); + headers.insert( + axum::http::header::HeaderName::from_static("proxy-connection"), + axum::http::HeaderValue::from_static("keep-alive"), + ); + headers.insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + ); + headers.insert( + axum::http::header::CONTENT_LENGTH, + axum::http::HeaderValue::from_static("12"), + ); + + strip_hop_by_hop_response_headers(&mut headers); + + assert!(!headers.contains_key(axum::http::header::CONNECTION)); + assert!(!headers.contains_key("keep-alive")); + assert!(!headers.contains_key(axum::http::header::TRANSFER_ENCODING)); + assert!(!headers.contains_key("proxy-connection")); + assert_eq!( + headers.get(axum::http::header::CONTENT_TYPE), + Some(&axum::http::HeaderValue::from_static("application/json")) + ); + assert_eq!( + headers.get(axum::http::header::CONTENT_LENGTH), + Some(&axum::http::HeaderValue::from_static("12")) + ); + } + + #[test] + fn test_strip_hop_by_hop_response_headers_removes_connection_listed_extensions() { + let mut headers = HeaderMap::new(); + headers.append( + axum::http::header::CONNECTION, + axum::http::HeaderValue::from_static("x-trace-hop, x-debug-hop"), + ); + headers.append( + axum::http::header::CONNECTION, + axum::http::HeaderValue::from_static("upgrade"), + ); + headers.insert( + axum::http::header::HeaderName::from_static("x-trace-hop"), + axum::http::HeaderValue::from_static("trace"), + ); + headers.insert( + axum::http::header::HeaderName::from_static("x-debug-hop"), + axum::http::HeaderValue::from_static("debug"), + ); + headers.insert( + axum::http::header::UPGRADE, + axum::http::HeaderValue::from_static("websocket"), + ); + headers.insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("text/event-stream"), + ); + + strip_hop_by_hop_response_headers(&mut headers); + + assert!(!headers.contains_key(axum::http::header::CONNECTION)); + assert!(!headers.contains_key("x-trace-hop")); + assert!(!headers.contains_key("x-debug-hop")); + assert!(!headers.contains_key(axum::http::header::UPGRADE)); + assert_eq!( + headers.get(axum::http::header::CONTENT_TYPE), + Some(&axum::http::HeaderValue::from_static("text/event-stream")) + ); + } + fn build_state(db: Arc) -> ProxyState { ProxyState { db: db.clone(), diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index ef545fdf1..ffe679e61 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -428,8 +428,7 @@ impl StreamCheckService { .header("x-stainless-retry-count", "0") .header("x-stainless-timeout", "600") // Other headers - .header("sec-fetch-mode", "cors") - .header("connection", "keep-alive"); + .header("sec-fetch-mode", "cors"); } // 供应商自定义 headers 最后追加,允许覆盖内置默认值(例如 user-agent) From 507bf038a9b4f20141dbdcfc42e0815255eda303 Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Wed, 15 Apr 2026 15:25:32 +0800 Subject: [PATCH 2/9] feat(stream-check): refresh default models and detect model-not-found errors (#2099) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(stream-check): update default health check models to latest Replaces deprecated gpt-5.1-codex@low with gpt-5.4@low and switches the Gemini default from gemini-3-pro-preview to gemini-3-flash-preview to pick the lightest variant of the latest series for fast, low-cost health checks. https://claude.ai/code/session_01NGWLchcTP76rJHjiP5Ehte * feat(stream-check): detect model-not-found errors with dedicated toast Health check previously classified failures purely by HTTP status code, which meant deprecated/invalid models showed up as a generic "Not found (404)" error pointing users to check the Base URL — misleading when the URL is fine and only the test model is wrong (e.g. gpt-5.1-codex after it was retired). Backend: add detect_error_category() that inspects 4xx response bodies for model-not-found indicators (model_not_found, does not exist, invalid model, not_found_error, etc.) and returns a "modelNotFound" category. Thread the resolved test model through build_stream_check_result so the failed result carries it in model_used. Add StreamCheckResult .error_category field (serde-skipped when None). Frontend: useStreamCheck branches on errorCategory === "modelNotFound" before the HTTP-status fallback and renders a toast.error with the model name and a description pointing to Model Test Config. Add i18n keys (modelNotFound / modelNotFoundHint) for zh/en/ja. Tests: unit-test detect_error_category against real OpenAI/Anthropic error shapes, 5xx false-positive avoidance, and plain 401 auth errors. https://claude.ai/code/session_01NGWLchcTP76rJHjiP5Ehte * fix(stream-check): add missing error_category field in fallback The error_category field was added to StreamCheckResult in this branch but the fallback constructor in stream_check_all_providers was not updated, which broke cargo build. --------- Co-authored-by: Claude --- src-tauri/src/commands/stream_check.rs | 1 + src-tauri/src/services/stream_check.rs | 111 ++++++++++++++++-- src/components/usage/ModelTestConfigPanel.tsx | 2 +- src/hooks/useStreamCheck.ts | 16 +++ src/i18n/locales/en.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/zh.json | 2 + src/lib/api/model-test.ts | 2 + 8 files changed, 128 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/commands/stream_check.rs b/src-tauri/src/commands/stream_check.rs index be0de3f12..9fc605775 100644 --- a/src-tauri/src/commands/stream_check.rs +++ b/src-tauri/src/commands/stream_check.rs @@ -133,6 +133,7 @@ pub async fn stream_check_all_providers( model_used: String::new(), tested_at: chrono::Utc::now().timestamp(), retry_count: 0, + error_category: None, } }); diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index ffe679e61..de1e4cc23 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -55,8 +55,8 @@ impl Default for StreamCheckConfig { max_retries: 2, degraded_threshold_ms: 6000, claude_model: "claude-haiku-4-5-20251001".to_string(), - codex_model: "gpt-5.1-codex@low".to_string(), - gemini_model: "gemini-3-pro-preview".to_string(), + codex_model: "gpt-5.4@low".to_string(), + gemini_model: "gemini-3-flash-preview".to_string(), test_prompt: default_test_prompt(), } } @@ -74,6 +74,9 @@ pub struct StreamCheckResult { pub model_used: String, pub tested_at: i64, pub retry_count: u32, + /// 细粒度错误分类(如 "modelNotFound"),前端据此渲染专门的文案 + #[serde(skip_serializing_if = "Option::is_none")] + pub error_category: Option, } /// 流式健康检查服务 @@ -143,6 +146,7 @@ impl StreamCheckService { model_used: String::new(), tested_at: chrono::Utc::now().timestamp(), retry_count: effective_config.max_retries, + error_category: None, })) } @@ -275,6 +279,7 @@ impl StreamCheckService { result, response_time, config.degraded_threshold_ms, + &model_to_test, )) } @@ -670,16 +675,21 @@ impl StreamCheckService { result, response_time, config.degraded_threshold_ms, + &model_to_test, )) } /// 将 check_*_stream 的原始结果包装成 StreamCheckResult /// /// 抽取自 check_once 的末尾逻辑,以便 OpenCode/OpenClaw 的独立分支复用。 + /// + /// `model_tested` 是本次探测使用的模型名,用于在失败场景下仍能把模型信息透传给前端, + /// 方便针对"模型不存在 / 已下架"这类错误渲染专门的提示。 fn build_stream_check_result( result: Result<(u16, String), AppError>, response_time: u64, degraded_threshold_ms: u64, + model_tested: &str, ) -> StreamCheckResult { let tested_at = chrono::Utc::now().timestamp(); match result { @@ -692,14 +702,19 @@ impl StreamCheckService { model_used: model, tested_at, retry_count: 0, + error_category: None, }, Err(e) => { - let (http_status, message) = match &e { - AppError::HttpStatus { status, .. } => ( - Some(*status), - Self::classify_http_status(*status).to_string(), - ), - _ => (None, e.to_string()), + let (http_status, message, error_category) = match &e { + AppError::HttpStatus { status, body } => { + let category = Self::detect_error_category(*status, body); + ( + Some(*status), + Self::classify_http_status(*status).to_string(), + category.map(|s| s.to_string()), + ) + } + _ => (None, e.to_string(), None), }; StreamCheckResult { status: HealthStatus::Failed, @@ -707,14 +722,47 @@ impl StreamCheckService { message, response_time_ms: Some(response_time), http_status, - model_used: String::new(), + model_used: model_tested.to_string(), tested_at, retry_count: 0, + error_category, } } } } + /// 基于 HTTP 状态码和响应体识别细粒度错误分类。 + /// + /// 目前仅识别"模型不存在 / 已下架":各厂商该类错误通常返回 4xx,body 中会包含 + /// 如 `model_not_found`(OpenAI)、`does not exist`、`invalid model`、`not_found_error` + /// + `model` 字样(Anthropic)等标记。 + pub(crate) fn detect_error_category(status: u16, body: &str) -> Option<&'static str> { + // 只检查 4xx;5xx 的错误信息里可能巧合出现"model"之类的词,容易误判 + if !(400..500).contains(&status) { + return None; + } + let lower = body.to_lowercase(); + // 必须提到 "model",避免通用 404 / 400 被误判 + if !lower.contains("model") { + return None; + } + let indicators = [ + "model_not_found", + "model not found", + "does not exist", + "invalid_model", + "invalid model", + "unknown_model", + "unknown model", + "is not a valid model", + "not_found_error", // Anthropic 的 type 字段 + ]; + if indicators.iter().any(|s| lower.contains(s)) { + return Some("modelNotFound"); + } + None + } + /// OpenClaw 流式检查分发器 /// /// 根据 `settings_config.api` 字段分发到对应协议的检查器。 @@ -1474,6 +1522,51 @@ mod tests { assert_eq!(effort, None); } + #[test] + fn test_detect_model_not_found() { + // OpenAI 典型响应:404 + model_not_found 错误码 + let openai_404 = r#"{"error":{"message":"The model `gpt-5.1-codex` does not exist or you do not have access to it","type":"invalid_request_error","param":null,"code":"model_not_found"}}"#; + assert_eq!( + StreamCheckService::detect_error_category(404, openai_404), + Some("modelNotFound") + ); + + // Anthropic 典型响应:404 + not_found_error + 提到 model + let anthropic_404 = r#"{"type":"error","error":{"type":"not_found_error","message":"model: claude-deprecated"}}"#; + assert_eq!( + StreamCheckService::detect_error_category(404, anthropic_404), + Some("modelNotFound") + ); + + // 400 + invalid model 也算 + let bad_req = r#"{"error":{"message":"invalid model specified"}}"#; + assert_eq!( + StreamCheckService::detect_error_category(400, bad_req), + Some("modelNotFound") + ); + + // 通用 404(比如 Base URL 错误),body 里没有 model 字样 → 不应误判 + let generic_404 = r#"{"error":"Not Found"}"#; + assert_eq!( + StreamCheckService::detect_error_category(404, generic_404), + None + ); + + // 5xx 就算 body 里有 "model does not exist" 也不分类(避免误判) + let server_error = r#"{"error":"model does not exist"}"#; + assert_eq!( + StreamCheckService::detect_error_category(500, server_error), + None + ); + + // 401 鉴权错误(body 里没有 model 字样) + let auth_err = r#"{"error":"Invalid API key"}"#; + assert_eq!( + StreamCheckService::detect_error_category(401, auth_err), + None + ); + } + #[test] fn test_get_os_name() { let os_name = StreamCheckService::get_os_name(); diff --git a/src/components/usage/ModelTestConfigPanel.tsx b/src/components/usage/ModelTestConfigPanel.tsx index cb6e9feff..65dba22e9 100644 --- a/src/components/usage/ModelTestConfigPanel.tsx +++ b/src/components/usage/ModelTestConfigPanel.tsx @@ -25,7 +25,7 @@ export function ModelTestConfigPanel() { degradedThresholdMs: "6000", claudeModel: "claude-haiku-4-5-20251001", codexModel: "gpt-5.4@low", - geminiModel: "gemini-3-pro-preview", + geminiModel: "gemini-3-flash-preview", testPrompt: "Who are you?", }); diff --git a/src/hooks/useStreamCheck.ts b/src/hooks/useStreamCheck.ts index c30667d76..196e65e9f 100644 --- a/src/hooks/useStreamCheck.ts +++ b/src/hooks/useStreamCheck.ts @@ -46,6 +46,22 @@ export function useStreamCheck(appId: AppId) { // 降级状态也重置熔断器,因为至少能通信 resetCircuitBreaker.mutate({ providerId, appType: appId }); + } else if (result.errorCategory === "modelNotFound") { + // 专门处理"模型不存在/已下架":指向配置入口,比通用 404 文案更有指导性 + toast.error( + t("streamCheck.modelNotFound", { + providerName: providerName, + model: result.modelUsed, + defaultValue: `${providerName} 测试模型 ${result.modelUsed} 不存在或已下架`, + }), + { + description: t("streamCheck.modelNotFoundHint", { + defaultValue: "", + }), + duration: 10000, + closeButton: true, + }, + ); } else { const httpStatus = result.httpStatus; const hintKey = httpStatus diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 497b7f6da..7cfc3f5a6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2131,6 +2131,8 @@ "failed": "{{providerName}} check failed: {{message}}", "rejected": "{{providerName}} check rejected: {{message}}", "error": "{{providerName}} check error: {{error}}", + "modelNotFound": "{{providerName}} test model {{model}} does not exist or has been deprecated", + "modelNotFoundHint": "This model may have been retired by the provider. Update the default test model in \"Model Test Config\".", "httpHint": { "400": "Provider rejected request format. Health check probe may differ from actual usage.", "401": "API key may be invalid, or provider uses OAuth auth. Check failure doesn't mean it's unusable.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 97f3df68b..2d867c394 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2131,6 +2131,8 @@ "failed": "{{providerName}} のチェックに失敗しました: {{message}}", "rejected": "{{providerName}} のチェックが拒否されました: {{message}}", "error": "{{providerName}} のチェックでエラーが発生しました: {{error}}", + "modelNotFound": "{{providerName}} のテストモデル {{model}} は存在しないか廃止されています", + "modelNotFoundHint": "このモデルはプロバイダーにより廃止された可能性があります。「モデルテスト設定」でデフォルトのテストモデルを更新してください。", "httpHint": { "400": "リクエスト形式が拒否されました。ヘルスチェックの形式は実際の使用と異なる場合があります。", "401": "APIキーが無効か、OAuthなどの認証方式を使用しています。チェック失敗は実際に使えないことを意味しません。", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index e7022ecf8..3a5069b95 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2132,6 +2132,8 @@ "failed": "{{providerName}} 检查失败: {{message}}", "rejected": "{{providerName}} 检查被拒: {{message}}", "error": "{{providerName}} 检查出错: {{error}}", + "modelNotFound": "{{providerName}} 测试模型 {{model}} 不存在或已下架", + "modelNotFoundHint": "该模型可能已被供应商弃用。请在\"模型测试配置\"中更新默认测试模型。", "httpHint": { "400": "供应商拒绝了请求格式。健康检查的探测格式可能与实际使用不同。", "401": "API Key 可能无效,或供应商使用 OAuth 等认证方式。检查失败不代表实际不可用。", diff --git a/src/lib/api/model-test.ts b/src/lib/api/model-test.ts index 4fc1c5936..7f69d2088 100644 --- a/src/lib/api/model-test.ts +++ b/src/lib/api/model-test.ts @@ -24,6 +24,8 @@ export interface StreamCheckResult { modelUsed: string; testedAt: number; retryCount: number; + /** 细粒度错误分类,如 "modelNotFound" */ + errorCategory?: string; } // ===== 流式健康检查 API ===== From de23216e491092b0d1f7df5a7cf43065a339795c Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Thu, 16 Apr 2026 17:00:28 +0800 Subject: [PATCH 3/9] feat(usage): refine usage dashboard UI and date range picker (#2002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(usage): enhance usage stats backend and query hooks * feat(usage): redesign calendar date range picker with auto-switch and simplified layout * refactor(usage): streamline dashboard layout and stats components * refactor(usage): compact request log table with merged cache/multiplier columns and centered layout * feat(i18n): add cache short labels and usage stats translations for zh/en/ja * Align usage dashboard stats with range boundaries The usage dashboard mixed second-precision detail rows with day-level rollups, which caused custom half-day ranges to overcount historical rollup data and left the request log paginator on stale pages after top-level filter changes. This change limits rollups to fully covered local days, aligns multi-day trend buckets with natural local days, and resets request log pagination when the dashboard range or app filter changes. Constraint: usage_daily_rollups stores only daily aggregates after pruning old detail rows Rejected: Include partial boundary rollups proportionally | historical intra-day detail is unavailable after pruning Rejected: Force RequestLogTable remount on range change | would discard local draft filters unnecessarily Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep summary, trends, provider stats, and model stats on the same rollup-boundary rules Tested: cargo test --manifest-path src-tauri/Cargo.toml usage_stats Tested: pnpm exec vitest run tests/components/RequestLogTable.test.tsx Tested: pnpm typecheck Not-tested: Manual UI validation in the Tauri app * Preserve full-day usage filters at minute precision The latest review surfaced two interaction bugs in the usage dashboard: rollup-backed stats undercounted end days selected via the minute-precision picker, and immediate select changes accidentally applied unsubmitted text drafts from the request log filters. This change treats 23:59 as a fully selected local end day for rollup inclusion and narrows select-side state syncing so app/status updates do not commit provider/model drafts. Constraint: The custom range picker emits minute-precision timestamps, while rollups are stored at day granularity Rejected: Require exact 23:59:59 end timestamps | unreachable from the current picker UI Rejected: Rebuild applied filters from the full draft state on select changes | silently commits unsaved text input Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep request-log text fields on explicit apply semantics even when select filters remain immediate Tested: cargo test --manifest-path src-tauri/Cargo.toml usage_stats Tested: pnpm exec vitest run tests/components/RequestLogTable.test.tsx Tested: pnpm typecheck Not-tested: Manual Tauri dashboard interaction * refactor(usage): move range presets into date picker, single-row layout - UsageDateRangePicker: add preset shortcuts (今天/1d/7d/14d/30d) inside popover top; clicking a preset applies immediately and closes popover - UsageDashboard: collapse to single row (app filters + refresh + picker); remove standalone preset buttons and summary stats bar - RequestLogTable: replace static Calendar badge with interactive UsageDateRangePicker via onRangeChange prop; single filter row * Keep usage pagination regression coverage aligned with the rendered UI The new regression test was asserting a non-existent pagination label and page summary text, so it failed before it could verify the real page-reset behavior. This commit switches the assertions to the numbered pagination buttons that the component actually renders and validates the reset through the query hook arguments. Constraint: RequestLogTable exposes numbered pagination buttons, not a "Next page" label or "2 / 6" summary text Rejected: Add synthetic pagination labels solely for the test | would couple production markup to a test-only assumption Confidence: high Scope-risk: narrow Reversibility: clean Directive: Prefer pagination assertions that follow the rendered controls or hook inputs instead of invented summary text Tested: pnpm vitest run tests/components/RequestLogTable.test.tsx; pnpm typecheck; pnpm test:unit * refactor(usage): clean up dead code and polish date range picker - Remove unused exports MAX_CUSTOM_USAGE_RANGE_SECONDS, timestampToLocalDatetime, and localDatetimeToTimestamp from usageRange.ts (replaced by the calendar picker) - Deduplicate getPresetLabel from UsageDashboard and UsageDateRangePicker into shared getUsageRangePresetLabel helper - Add aria-label, aria-current and aria-pressed to calendar day buttons so screen readers can disambiguate same-numbered days across adjacent months - Drop unused cacheReadShort and cacheWriteShort i18n keys (zh/en/ja); the request log table renders R/W prefixes inline - Align customRangeHint copy with the removed 30-day limit by dropping "up to 30 days" wording (zh/en/ja) * fix(usage): align rollup cutoff to local midnight to keep days complete `rollup_and_prune` previously used `Utc::now() - retain_days * 86400` as the cutoff. Because rollups are bucketed by *local* date and detail rows below the cutoff are pruned, an unaligned cutoff left the youngest rolled-up day half-rolled-up and half-pruned. Combined with the new `compute_rollup_date_bounds` boundary trimming (which excludes any rollup day not fully covered by the requested range), custom range queries that touch that day silently under-count summary, trend, provider, and model stats. Fix the invariant at the source: snap the cutoff to the next local midnight after `(now - retain_days)`. Every rollup row now reflects a complete local day, so the boundary trimmer's all-or-nothing assumption holds. Includes unit tests for the cutoff math (typical case + already-on- midnight case). DST gap is handled defensively by bumping forward by an hour. Addresses Codex P2 review finding on PR #2002. --------- Co-authored-by: Jason --- src-tauri/src/commands/usage.rs | 12 +- src-tauri/src/database/dao/usage_rollup.rs | 91 +- src-tauri/src/services/usage_stats.rs | 1040 ++++++++++++++--- src/components/usage/ModelStatsTable.tsx | 5 +- src/components/usage/ProviderStatsTable.tsx | 5 +- src/components/usage/RequestLogTable.tsx | 694 ++++------- src/components/usage/UsageDashboard.tsx | 145 +-- src/components/usage/UsageDateRangePicker.tsx | 439 +++++++ src/components/usage/UsageSummaryCards.tsx | 7 +- src/components/usage/UsageTrendChart.tsx | 24 +- src/i18n/locales/en.json | 9 + src/i18n/locales/ja.json | 9 + src/i18n/locales/zh.json | 9 + src/lib/api/usage.ts | 16 +- src/lib/query/usage.ts | 167 ++- src/lib/usageRange.ts | 79 ++ src/types/usage.ts | 10 +- tests/components/RequestLogTable.test.tsx | 161 +++ 18 files changed, 2156 insertions(+), 766 deletions(-) create mode 100644 src/components/usage/UsageDateRangePicker.tsx create mode 100644 src/lib/usageRange.ts create mode 100644 tests/components/RequestLogTable.test.tsx diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 79a3280fa..8c9047310 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -35,18 +35,26 @@ pub fn get_usage_trends( #[tauri::command] pub fn get_provider_stats( state: State<'_, AppState>, + start_date: Option, + end_date: Option, app_type: Option, ) -> Result, AppError> { - state.db.get_provider_stats(app_type.as_deref()) + state + .db + .get_provider_stats(start_date, end_date, app_type.as_deref()) } /// 获取模型统计 #[tauri::command] pub fn get_model_stats( state: State<'_, AppState>, + start_date: Option, + end_date: Option, app_type: Option, ) -> Result, AppError> { - state.db.get_model_stats(app_type.as_deref()) + state + .db + .get_model_stats(start_date, end_date, app_type.as_deref()) } /// 获取请求日志列表 diff --git a/src-tauri/src/database/dao/usage_rollup.rs b/src-tauri/src/database/dao/usage_rollup.rs index ab0c04a91..fffb8a3bf 100644 --- a/src-tauri/src/database/dao/usage_rollup.rs +++ b/src-tauri/src/database/dao/usage_rollup.rs @@ -4,13 +4,61 @@ use crate::database::{lock_conn, Database}; use crate::error::AppError; +use chrono::{Duration, Local, TimeZone}; + +/// Compute the rollup/prune cutoff aligned to a local-day boundary. +/// +/// Anything strictly older than the returned timestamp will be aggregated into +/// `usage_daily_rollups` and deleted from `proxy_request_logs`. Aligning to the +/// next local midnight after `(now - retain_days)` guarantees that the youngest +/// rollup row always represents a *complete* local day. Without this alignment +/// the cutoff falls mid-day, leaving the day half-rolled-up and half-pruned — +/// which would silently under-count any range query that touches that day +/// after `compute_rollup_date_bounds` trims partial-coverage rollup days. +fn compute_local_midnight_cutoff( + now: chrono::DateTime, + retain_days: i64, +) -> Result { + let target_day = now + .checked_sub_signed(Duration::days(retain_days)) + .ok_or_else(|| AppError::Database("rollup cutoff overflow".to_string()))? + .date_naive(); + + // Use the *next* day's midnight so anything before it has fully been bucketed. + let next_day = target_day + .succ_opt() + .ok_or_else(|| AppError::Database("rollup cutoff next-day overflow".to_string()))?; + let naive_midnight = next_day + .and_hms_opt(0, 0, 0) + .ok_or_else(|| AppError::Database("rollup cutoff midnight overflow".to_string()))?; + + let local_dt = match Local.from_local_datetime(&naive_midnight) { + chrono::LocalResult::Single(dt) => dt, + chrono::LocalResult::Ambiguous(earliest, _) => earliest, + chrono::LocalResult::None => { + // DST gap: fall back to one hour later, which always exists. + let bumped = naive_midnight + Duration::hours(1); + match Local.from_local_datetime(&bumped) { + chrono::LocalResult::Single(dt) => dt, + chrono::LocalResult::Ambiguous(earliest, _) => earliest, + chrono::LocalResult::None => { + return Err(AppError::Database( + "rollup cutoff fell into DST gap".to_string(), + )) + } + } + } + }; + + Ok(local_dt.timestamp()) +} impl Database { /// Aggregate proxy_request_logs older than `retain_days` into usage_daily_rollups, /// then delete the aggregated detail rows. /// Returns the number of deleted detail rows. pub fn rollup_and_prune(&self, retain_days: i64) -> Result { - let cutoff = chrono::Utc::now().timestamp() - retain_days * 86400; + let cutoff = compute_local_midnight_cutoff(Local::now(), retain_days)?; let conn = lock_conn!(self.conn); // Check if there are any rows to process @@ -110,8 +158,49 @@ impl Database { #[cfg(test)] mod tests { + use super::compute_local_midnight_cutoff; use crate::database::Database; use crate::error::AppError; + use chrono::{Local, TimeZone}; + + fn local_dt( + year: i32, + month: u32, + day: u32, + hour: u32, + minute: u32, + second: u32, + ) -> chrono::DateTime { + match Local.with_ymd_and_hms(year, month, day, hour, minute, second) { + chrono::LocalResult::Single(dt) => dt, + chrono::LocalResult::Ambiguous(earliest, _) => earliest, + chrono::LocalResult::None => panic!("invalid local datetime in test fixture"), + } + } + + #[test] + fn cutoff_is_aligned_to_local_midnight_after_target_day() -> Result<(), AppError> { + // now = 2026-04-16 14:32:17 local; retain_days = 30 + // target day = 2026-03-17; cutoff should be 2026-03-18 00:00 local. + let now = local_dt(2026, 4, 16, 14, 32, 17); + let cutoff_ts = compute_local_midnight_cutoff(now, 30)?; + let cutoff_dt = Local.timestamp_opt(cutoff_ts, 0).single().unwrap(); + let expected = local_dt(2026, 3, 18, 0, 0, 0); + assert_eq!(cutoff_dt, expected); + Ok(()) + } + + #[test] + fn cutoff_at_local_midnight_now_still_lands_on_midnight() -> Result<(), AppError> { + // If `now` is itself local midnight, the math should not introduce drift. + let now = local_dt(2026, 4, 16, 0, 0, 0); + let cutoff_ts = compute_local_midnight_cutoff(now, 7)?; + let cutoff_dt = Local.timestamp_opt(cutoff_ts, 0).single().unwrap(); + // (2026-04-16 - 7d) = 2026-04-09; cutoff = 2026-04-10 00:00 local. + let expected = local_dt(2026, 4, 10, 0, 0, 0); + assert_eq!(cutoff_dt, expected); + Ok(()) + } #[test] fn test_rollup_and_prune() -> Result<(), AppError> { diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index b43ea714a..706fd9f09 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -4,7 +4,7 @@ use crate::database::{lock_conn, Database}; use crate::error::AppError; -use chrono::{Local, TimeZone}; +use chrono::{Local, NaiveDate, TimeZone, Timelike}; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -130,6 +130,96 @@ fn provider_name_coalesce(log_alias: &str, provider_alias: &str) -> String { ) } +#[derive(Debug, Clone, Default)] +struct RollupDateBounds { + start: Option, + end: Option, + is_empty: bool, +} + +fn local_datetime_from_timestamp(ts: i64) -> Result, AppError> { + Local + .timestamp_opt(ts, 0) + .single() + .ok_or_else(|| AppError::Database(format!("无法解析本地时间戳: {ts}"))) +} + +fn compute_rollup_date_bounds( + start_ts: Option, + end_ts: Option, +) -> Result { + let start = match start_ts { + Some(ts) => { + let local = local_datetime_from_timestamp(ts)?; + let day = local.date_naive(); + if local.time().num_seconds_from_midnight() == 0 { + Some(day.format("%Y-%m-%d").to_string()) + } else { + day.succ_opt() + .map(|next| next.format("%Y-%m-%d").to_string()) + } + } + None => None, + }; + + let end = match end_ts { + Some(ts) => { + let local = local_datetime_from_timestamp(ts)?; + let day = local.date_naive(); + if local.time().hour() == 23 && local.time().minute() == 59 { + Some(day.format("%Y-%m-%d").to_string()) + } else { + day.pred_opt() + .map(|prev| prev.format("%Y-%m-%d").to_string()) + } + } + None => None, + }; + + let is_empty = matches!((&start, &end), (Some(start), Some(end)) if start > end); + + Ok(RollupDateBounds { + start, + end, + is_empty, + }) +} + +fn push_rollup_date_filters( + conditions: &mut Vec, + params: &mut Vec>, + column: &str, + bounds: &RollupDateBounds, +) { + if bounds.is_empty { + conditions.push("1 = 0".to_string()); + return; + } + + if let Some(start) = &bounds.start { + conditions.push(format!("{column} >= ?")); + params.push(Box::new(start.clone())); + } + + if let Some(end) = &bounds.end { + conditions.push(format!("{column} <= ?")); + params.push(Box::new(end.clone())); + } +} + +fn local_day_start_rfc3339(day: NaiveDate) -> String { + let local_midnight = day + .and_hms_opt(0, 0, 0) + .and_then(|naive| match Local.from_local_datetime(&naive) { + chrono::LocalResult::Single(dt) => Some(dt), + chrono::LocalResult::Ambiguous(earliest, _) => Some(earliest), + chrono::LocalResult::None => None, + }) + .unwrap_or_else(Local::now); + + local_midnight.to_rfc3339() +} + impl Database { /// 获取使用量汇总 pub fn get_usage_summary( @@ -163,18 +253,17 @@ impl Database { format!("WHERE {}", conditions.join(" AND ")) }; - // Build rollup WHERE clause using date strings + // Only include rolled-up rows for full local days that are fully covered by the range. let mut rollup_conditions: Vec = Vec::new(); let mut rollup_params: Vec> = Vec::new(); + let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?; - if let Some(start) = start_date { - rollup_conditions.push("date >= date(?, 'unixepoch', 'localtime')".to_string()); - rollup_params.push(Box::new(start)); - } - if let Some(end) = end_date { - rollup_conditions.push("date <= date(?, 'unixepoch', 'localtime')".to_string()); - rollup_params.push(Box::new(end)); - } + push_rollup_date_filters( + &mut rollup_conditions, + &mut rollup_params, + "date", + &rollup_bounds, + ); if let Some(at) = app_type { rollup_conditions.push("app_type = ?".to_string()); rollup_params.push(Box::new(at.to_string())); @@ -267,70 +356,59 @@ impl Database { } let duration = end_ts - start_ts; - let bucket_seconds: i64 = if duration <= 24 * 60 * 60 { - 60 * 60 - } else { - 24 * 60 * 60 - }; - let mut bucket_count: i64 = if duration <= 0 { - 1 - } else { - ((duration as f64) / bucket_seconds as f64).ceil() as i64 - }; + if duration <= 24 * 60 * 60 { + let bucket_seconds: i64 = 60 * 60; + let mut bucket_count: i64 = if duration <= 0 { + 1 + } else { + (duration + bucket_seconds - 1) / bucket_seconds + }; - // 固定 24 小时窗口为 24 个小时桶,避免浮点误差 - if bucket_seconds == 60 * 60 { - bucket_count = 24; - } + if bucket_count < 1 { + bucket_count = 1; + } - if bucket_count < 1 { - bucket_count = 1; - } + let app_type_filter = if app_type.is_some() { + "AND app_type = ?4" + } else { + "" + }; - let app_type_filter = if app_type.is_some() { - "AND app_type = ?4" - } else { - "" - }; + let sql = format!( + "SELECT + CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx, + COUNT(*) as request_count, + COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens + FROM proxy_request_logs + WHERE created_at >= ?1 AND created_at <= ?2 {app_type_filter} + GROUP BY bucket_idx + ORDER BY bucket_idx ASC" + ); - // Query detail logs - let sql = format!( - "SELECT - CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx, - COUNT(*) as request_count, - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, - COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens - FROM proxy_request_logs - WHERE created_at >= ?1 AND created_at <= ?2 {app_type_filter} - GROUP BY bucket_idx - ORDER BY bucket_idx ASC" - ); + let mut stmt = conn.prepare(&sql)?; + let row_mapper = |row: &rusqlite::Row| { + Ok(( + row.get::<_, i64>(0)?, + DailyStats { + date: String::new(), + request_count: row.get::<_, i64>(1)? as u64, + total_cost: format!("{:.6}", row.get::<_, f64>(2)?), + total_tokens: row.get::<_, i64>(3)? as u64, + total_input_tokens: row.get::<_, i64>(4)? as u64, + total_output_tokens: row.get::<_, i64>(5)? as u64, + total_cache_creation_tokens: row.get::<_, i64>(6)? as u64, + total_cache_read_tokens: row.get::<_, i64>(7)? as u64, + }, + )) + }; - let mut stmt = conn.prepare(&sql)?; - let row_mapper = |row: &rusqlite::Row| { - Ok(( - row.get::<_, i64>(0)?, - DailyStats { - date: String::new(), - request_count: row.get::<_, i64>(1)? as u64, - total_cost: format!("{:.6}", row.get::<_, f64>(2)?), - total_tokens: row.get::<_, i64>(3)? as u64, - total_input_tokens: row.get::<_, i64>(4)? as u64, - total_output_tokens: row.get::<_, i64>(5)? as u64, - total_cache_creation_tokens: row.get::<_, i64>(6)? as u64, - total_cache_read_tokens: row.get::<_, i64>(7)? as u64, - }, - )) - }; + let mut map: HashMap = HashMap::new(); - let mut map: HashMap = HashMap::new(); - - // Collect rows into map (need to handle both param variants) - { let rows = if let Some(at) = app_type { stmt.query_map(params![start_ts, end_ts, bucket_seconds, at], row_mapper)? } else { @@ -346,91 +424,175 @@ impl Database { } map.insert(bucket_idx, stat); } - } - // Also query rollup data (daily granularity, only useful for daily buckets) - if bucket_seconds >= 86400 { - let rollup_sql = format!( - "SELECT - CAST((CAST(strftime('%s', date) AS INTEGER) - ?1) / ?3 AS INTEGER) as bucket_idx, - COALESCE(SUM(request_count), 0), - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0), - COALESCE(SUM(input_tokens + output_tokens), 0), - COALESCE(SUM(input_tokens), 0), - COALESCE(SUM(output_tokens), 0), - COALESCE(SUM(cache_creation_tokens), 0), - COALESCE(SUM(cache_read_tokens), 0) - FROM usage_daily_rollups - WHERE date >= date(?1, 'unixepoch', 'localtime') AND date <= date(?2, 'unixepoch', 'localtime') {app_type_filter} - GROUP BY bucket_idx - ORDER BY bucket_idx ASC" - ); + let mut stats = Vec::with_capacity(bucket_count as usize); + for i in 0..bucket_count { + let bucket_start_ts = start_ts + i * bucket_seconds; + let bucket_start = local_datetime_from_timestamp(bucket_start_ts)?; + let date = bucket_start.to_rfc3339(); - let rollup_row_mapper = |row: &rusqlite::Row| { - Ok(( - row.get::<_, i64>(0)?, - ( - row.get::<_, i64>(1)? as u64, - row.get::<_, f64>(2)?, - row.get::<_, i64>(3)? as u64, - row.get::<_, i64>(4)? as u64, - row.get::<_, i64>(5)? as u64, - row.get::<_, i64>(6)? as u64, - row.get::<_, i64>(7)? as u64, - ), - )) - }; - - let mut rstmt = conn.prepare(&rollup_sql)?; - let rrows = if let Some(at) = app_type { - rstmt.query_map( - params![start_ts, end_ts, bucket_seconds, at], - rollup_row_mapper, - )? - } else { - rstmt.query_map(params![start_ts, end_ts, bucket_seconds], rollup_row_mapper)? - }; - - for row in rrows { - let (mut bucket_idx, (req, cost, tok, inp, out, cc, cr)) = row?; - if bucket_idx < 0 { - continue; + if let Some(mut stat) = map.remove(&i) { + stat.date = date; + stats.push(stat); + } else { + stats.push(DailyStats { + date, + request_count: 0, + total_cost: "0.000000".to_string(), + total_tokens: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_creation_tokens: 0, + total_cache_read_tokens: 0, + }); } - if bucket_idx >= bucket_count { - bucket_idx = bucket_count - 1; - } - let entry = map.entry(bucket_idx).or_insert_with(|| DailyStats { - date: String::new(), - request_count: 0, - total_cost: "0.000000".to_string(), - total_tokens: 0, - total_input_tokens: 0, - total_output_tokens: 0, - total_cache_creation_tokens: 0, - total_cache_read_tokens: 0, - }); - entry.request_count += req; - let existing_cost: f64 = entry.total_cost.parse().unwrap_or(0.0); - entry.total_cost = format!("{:.6}", existing_cost + cost); - entry.total_tokens += tok; - entry.total_input_tokens += inp; - entry.total_output_tokens += out; - entry.total_cache_creation_tokens += cc; - entry.total_cache_read_tokens += cr; } + + return Ok(stats); } - let mut stats = Vec::with_capacity(bucket_count as usize); - for i in 0..bucket_count { - let bucket_start_ts = start_ts + i * bucket_seconds; - let bucket_start = Local - .timestamp_opt(bucket_start_ts, 0) - .single() - .unwrap_or_else(Local::now); + let start_day = local_datetime_from_timestamp(start_ts)?.date_naive(); + let end_day = local_datetime_from_timestamp(end_ts)?.date_naive(); + let bucket_count = (end_day.signed_duration_since(start_day).num_days() + 1) as usize; - let date = bucket_start.to_rfc3339(); + let app_type_filter = if app_type.is_some() { + "AND app_type = ?3" + } else { + "" + }; - if let Some(mut stat) = map.remove(&i) { + let detail_sql = format!( + "SELECT + date(created_at, 'unixepoch', 'localtime') as bucket_date, + COUNT(*) as request_count, + COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens + FROM proxy_request_logs + WHERE created_at >= ?1 AND created_at <= ?2 {app_type_filter} + GROUP BY bucket_date + ORDER BY bucket_date ASC" + ); + + let mut detail_stmt = conn.prepare(&detail_sql)?; + let detail_row_mapper = |row: &rusqlite::Row| { + Ok(( + row.get::<_, String>(0)?, + DailyStats { + date: String::new(), + request_count: row.get::<_, i64>(1)? as u64, + total_cost: format!("{:.6}", row.get::<_, f64>(2)?), + total_tokens: row.get::<_, i64>(3)? as u64, + total_input_tokens: row.get::<_, i64>(4)? as u64, + total_output_tokens: row.get::<_, i64>(5)? as u64, + total_cache_creation_tokens: row.get::<_, i64>(6)? as u64, + total_cache_read_tokens: row.get::<_, i64>(7)? as u64, + }, + )) + }; + + let mut map: HashMap = HashMap::new(); + let detail_rows = if let Some(at) = app_type { + detail_stmt.query_map(params![start_ts, end_ts, at], detail_row_mapper)? + } else { + detail_stmt.query_map(params![start_ts, end_ts], detail_row_mapper)? + }; + + for row in detail_rows { + let (bucket_date, stat) = row?; + let date = NaiveDate::parse_from_str(&bucket_date, "%Y-%m-%d") + .map_err(|err| AppError::Database(format!("解析趋势日期失败: {err}")))?; + map.insert(date, stat); + } + + let rollup_bounds = compute_rollup_date_bounds(Some(start_ts), Some(end_ts))?; + let mut rollup_conditions = Vec::new(); + let mut rollup_params: Vec> = Vec::new(); + push_rollup_date_filters( + &mut rollup_conditions, + &mut rollup_params, + "date", + &rollup_bounds, + ); + if let Some(at) = app_type { + rollup_conditions.push("app_type = ?".to_string()); + rollup_params.push(Box::new(at.to_string())); + } + + let rollup_where = if rollup_conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", rollup_conditions.join(" AND ")) + }; + + let rollup_sql = format!( + "SELECT + date, + COALESCE(SUM(request_count), 0), + COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0), + COALESCE(SUM(input_tokens + output_tokens), 0), + COALESCE(SUM(input_tokens), 0), + COALESCE(SUM(output_tokens), 0), + COALESCE(SUM(cache_creation_tokens), 0), + COALESCE(SUM(cache_read_tokens), 0) + FROM usage_daily_rollups + {rollup_where} + GROUP BY date + ORDER BY date ASC" + ); + + let mut rollup_stmt = conn.prepare(&rollup_sql)?; + let rollup_row_mapper = |row: &rusqlite::Row| { + Ok(( + row.get::<_, String>(0)?, + ( + row.get::<_, i64>(1)? as u64, + row.get::<_, f64>(2)?, + row.get::<_, i64>(3)? as u64, + row.get::<_, i64>(4)? as u64, + row.get::<_, i64>(5)? as u64, + row.get::<_, i64>(6)? as u64, + row.get::<_, i64>(7)? as u64, + ), + )) + }; + let rollup_param_refs: Vec<&dyn rusqlite::ToSql> = + rollup_params.iter().map(|param| param.as_ref()).collect(); + let rollup_rows = rollup_stmt.query_map(rollup_param_refs.as_slice(), rollup_row_mapper)?; + + for row in rollup_rows { + let (bucket_date, (req, cost, tok, inp, out, cc, cr)) = row?; + let date = NaiveDate::parse_from_str(&bucket_date, "%Y-%m-%d") + .map_err(|err| AppError::Database(format!("解析 rollup 趋势日期失败: {err}")))?; + let entry = map.entry(date).or_insert_with(|| DailyStats { + date: String::new(), + request_count: 0, + total_cost: "0.000000".to_string(), + total_tokens: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_creation_tokens: 0, + total_cache_read_tokens: 0, + }); + entry.request_count += req; + let existing_cost: f64 = entry.total_cost.parse().unwrap_or(0.0); + entry.total_cost = format!("{:.6}", existing_cost + cost); + entry.total_tokens += tok; + entry.total_input_tokens += inp; + entry.total_output_tokens += out; + entry.total_cache_creation_tokens += cc; + entry.total_cache_read_tokens += cr; + } + + let mut stats = Vec::with_capacity(bucket_count); + let mut current_day = start_day; + for _ in 0..bucket_count { + let date = local_day_start_rfc3339(current_day); + + if let Some(mut stat) = map.remove(¤t_day) { stat.date = date; stats.push(stat); } else { @@ -445,6 +607,8 @@ impl Database { total_cache_read_tokens: 0, }); } + + current_day = current_day.succ_opt().unwrap_or(current_day); } Ok(stats) @@ -453,14 +617,49 @@ impl Database { /// 获取 Provider 统计 pub fn get_provider_stats( &self, + start_date: Option, + end_date: Option, app_type: Option<&str>, ) -> Result, AppError> { let conn = lock_conn!(self.conn); - let (detail_where, rollup_where) = if app_type.is_some() { - ("WHERE l.app_type = ?1", "WHERE r.app_type = ?2") + let mut detail_conditions = Vec::new(); + let mut detail_params: Vec> = Vec::new(); + if let Some(start) = start_date { + detail_conditions.push("l.created_at >= ?"); + detail_params.push(Box::new(start)); + } + if let Some(end) = end_date { + detail_conditions.push("l.created_at <= ?"); + detail_params.push(Box::new(end)); + } + if let Some(at) = app_type { + detail_conditions.push("l.app_type = ?"); + detail_params.push(Box::new(at.to_string())); + } + let detail_where = if detail_conditions.is_empty() { + String::new() } else { - ("", "") + format!("WHERE {}", detail_conditions.join(" AND ")) + }; + + let mut rollup_conditions = Vec::new(); + let mut rollup_params: Vec> = Vec::new(); + let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?; + push_rollup_date_filters( + &mut rollup_conditions, + &mut rollup_params, + "r.date", + &rollup_bounds, + ); + if let Some(at) = app_type { + rollup_conditions.push("r.app_type = ?".to_string()); + rollup_params.push(Box::new(at.to_string())); + } + let rollup_where = if rollup_conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", rollup_conditions.join(" AND ")) }; // UNION detail logs + rollup data, then aggregate @@ -506,6 +705,9 @@ impl Database { ); let mut stmt = conn.prepare(&sql)?; + let mut params: Vec> = detail_params; + params.extend(rollup_params); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let row_mapper = |row: &rusqlite::Row| { let request_count: i64 = row.get(3)?; let success_count: i64 = row.get(6)?; @@ -526,11 +728,7 @@ impl Database { }) }; - let rows = if let Some(at) = app_type { - stmt.query_map(params![at, at], row_mapper)? - } else { - stmt.query_map([], row_mapper)? - }; + let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?; let mut stats = Vec::new(); for row in rows { @@ -541,13 +739,51 @@ impl Database { } /// 获取模型统计 - pub fn get_model_stats(&self, app_type: Option<&str>) -> Result, AppError> { + pub fn get_model_stats( + &self, + start_date: Option, + end_date: Option, + app_type: Option<&str>, + ) -> Result, AppError> { let conn = lock_conn!(self.conn); - let (detail_where, rollup_where) = if app_type.is_some() { - ("WHERE app_type = ?1", "WHERE app_type = ?2") + let mut detail_conditions = Vec::new(); + let mut detail_params: Vec> = Vec::new(); + if let Some(start) = start_date { + detail_conditions.push("l.created_at >= ?"); + detail_params.push(Box::new(start)); + } + if let Some(end) = end_date { + detail_conditions.push("l.created_at <= ?"); + detail_params.push(Box::new(end)); + } + if let Some(at) = app_type { + detail_conditions.push("l.app_type = ?"); + detail_params.push(Box::new(at.to_string())); + } + let detail_where = if detail_conditions.is_empty() { + String::new() } else { - ("", "") + format!("WHERE {}", detail_conditions.join(" AND ")) + }; + + let mut rollup_conditions = Vec::new(); + let mut rollup_params: Vec> = Vec::new(); + let rollup_bounds = compute_rollup_date_bounds(start_date, end_date)?; + push_rollup_date_filters( + &mut rollup_conditions, + &mut rollup_params, + "r.date", + &rollup_bounds, + ); + if let Some(at) = app_type { + rollup_conditions.push("r.app_type = ?".to_string()); + rollup_params.push(Box::new(at.to_string())); + } + let rollup_where = if rollup_conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", rollup_conditions.join(" AND ")) }; // UNION detail logs + rollup data @@ -558,27 +794,30 @@ impl Database { SUM(total_tokens) as total_tokens, SUM(total_cost) as total_cost FROM ( - SELECT model, + SELECT l.model, COUNT(*) as request_count, - COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost - FROM proxy_request_logs + COALESCE(SUM(l.input_tokens + l.output_tokens), 0) as total_tokens, + COALESCE(SUM(CAST(l.total_cost_usd AS REAL)), 0) as total_cost + FROM proxy_request_logs l {detail_where} - GROUP BY model + GROUP BY l.model UNION ALL - SELECT model, + SELECT r.model, COALESCE(SUM(request_count), 0), COALESCE(SUM(input_tokens + output_tokens), 0), COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) - FROM usage_daily_rollups + FROM usage_daily_rollups r {rollup_where} - GROUP BY model + GROUP BY r.model ) GROUP BY model ORDER BY total_cost DESC" ); let mut stmt = conn.prepare(&sql)?; + let mut params: Vec> = detail_params; + params.extend(rollup_params); + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); let row_mapper = |row: &rusqlite::Row| { let request_count: i64 = row.get(1)?; let total_cost: f64 = row.get(3)?; @@ -597,11 +836,7 @@ impl Database { }) }; - let rows = if let Some(at) = app_type { - stmt.query_map(params![at, at], row_mapper)? - } else { - stmt.query_map([], row_mapper)? - }; + let rows = stmt.query_map(param_refs.as_slice(), row_mapper)?; let mut stats = Vec::new(); for row in rows { @@ -1108,6 +1343,14 @@ pub(crate) fn find_model_pricing_row( mod tests { use super::*; + fn local_ts(year: i32, month: u32, day: u32, hour: u32, minute: u32, second: u32) -> i64 { + match Local.with_ymd_and_hms(year, month, day, hour, minute, second) { + chrono::LocalResult::Single(dt) => dt.timestamp(), + chrono::LocalResult::Ambiguous(earliest, _) => earliest.timestamp(), + chrono::LocalResult::None => panic!("valid local datetime"), + } + } + #[test] fn test_get_usage_summary() -> Result<(), AppError> { let db = Database::memory()?; @@ -1140,6 +1383,148 @@ mod tests { Ok(()) } + #[test] + fn test_get_usage_summary_excludes_partial_rollup_boundary_days() -> Result<(), AppError> { + let db = Database::memory()?; + let start = local_ts(2024, 1, 1, 12, 0, 0); + let end = local_ts(2024, 1, 3, 12, 0, 0); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-01-01", + "claude", + "p1", + "claude-3", + 10, + 10, + 1000, + 500, + 0, + 0, + "1.00", + 100 + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-01-02", + "claude", + "p1", + "claude-3", + 20, + 19, + 2000, + 1000, + 0, + 0, + "2.00", + 120 + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-01-03", + "claude", + "p1", + "claude-3", + 30, + 29, + 3000, + 1500, + 0, + 0, + "3.00", + 140 + ], + )?; + } + + let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"))?; + assert_eq!(summary.total_requests, 20); + assert_eq!(summary.total_input_tokens, 2000); + assert_eq!(summary.total_output_tokens, 1000); + + Ok(()) + } + + #[test] + fn test_get_usage_summary_includes_end_day_rollup_for_minute_precision_end_time( + ) -> Result<(), AppError> { + let db = Database::memory()?; + let start = local_ts(2024, 1, 1, 0, 0, 0); + let end = local_ts(2024, 1, 2, 23, 59, 0); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-01-01", + "claude", + "p1", + "claude-3", + 10, + 10, + 1000, + 500, + 0, + 0, + "1.00", + 100 + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-01-02", + "claude", + "p1", + "claude-3", + 20, + 19, + 2000, + 1000, + 0, + 0, + "2.00", + 120 + ], + )?; + } + + let summary = db.get_usage_summary(Some(start), Some(end), Some("claude"))?; + assert_eq!(summary.total_requests, 30); + assert_eq!(summary.total_input_tokens, 3000); + assert_eq!(summary.total_output_tokens, 1500); + + Ok(()) + } + #[test] fn test_get_model_stats() -> Result<(), AppError> { let db = Database::memory()?; @@ -1168,7 +1553,7 @@ mod tests { )?; } - let stats = db.get_model_stats(None)?; + let stats = db.get_model_stats(None, None, None)?; assert_eq!(stats.len(), 1); assert_eq!(stats[0].model, "claude-3-sonnet"); assert_eq!(stats[0].request_count, 1); @@ -1176,6 +1561,319 @@ mod tests { Ok(()) } + #[test] + fn test_get_provider_stats_with_time_filter() -> Result<(), AppError> { + let db = Database::memory()?; + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params!["old", "p1", "claude", "claude-3", 100, 50, "0.01", 100, 200, 1000], + )?; + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params!["new", "p1", "claude", "claude-3", 200, 75, "0.02", 120, 200, 2000], + )?; + } + + let stats = db.get_provider_stats(Some(1500), Some(2500), Some("claude"))?; + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].provider_id, "p1"); + assert_eq!(stats[0].request_count, 1); + assert_eq!(stats[0].total_tokens, 275); + + Ok(()) + } + + #[test] + fn test_get_provider_stats_excludes_partial_rollup_boundary_days() -> Result<(), AppError> { + let db = Database::memory()?; + let start = local_ts(2024, 2, 1, 12, 0, 0); + let end = local_ts(2024, 2, 3, 12, 0, 0); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-02-01", + "claude", + "p-rollup", + "claude-3", + 5, + 5, + 500, + 250, + 0, + 0, + "0.50", + 100 + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-02-02", + "claude", + "p-rollup", + "claude-3", + 8, + 7, + 800, + 400, + 0, + 0, + "0.80", + 120 + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-02-03", + "claude", + "p-rollup", + "claude-3", + 12, + 11, + 1200, + 600, + 0, + 0, + "1.20", + 140 + ], + )?; + } + + let stats = db.get_provider_stats(Some(start), Some(end), Some("claude"))?; + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].provider_id, "p-rollup"); + assert_eq!(stats[0].request_count, 8); + assert_eq!(stats[0].total_tokens, 1200); + + Ok(()) + } + + #[test] + fn test_get_daily_trends_respects_shorter_than_24_hours() -> Result<(), AppError> { + let db = Database::memory()?; + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "req-short", + "p1", + "claude", + "claude-3", + 100, + 50, + "0.01", + 100, + 200, + 10_800 + ], + )?; + } + + let stats = db.get_daily_trends(Some(0), Some(15 * 60 * 60), Some("claude"))?; + assert_eq!(stats.len(), 15); + assert_eq!(stats[3].request_count, 1); + + Ok(()) + } + + #[test] + fn test_get_daily_trends_groups_ranges_longer_than_24_hours_by_local_day( + ) -> Result<(), AppError> { + let db = Database::memory()?; + let start = local_ts(2024, 3, 1, 12, 0, 0); + let end = local_ts(2024, 3, 3, 12, 0, 0); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "day-1-detail", + "p1", + "claude", + "claude-3", + 100, + 50, + "0.01", + 100, + 200, + local_ts(2024, 3, 1, 13, 0, 0) + ], + )?; + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "day-3-detail", + "p1", + "claude", + "claude-3", + 200, + 75, + "0.02", + 110, + 200, + local_ts(2024, 3, 3, 10, 0, 0) + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-03-02", + "claude", + "p1", + "claude-3", + 4, + 4, + 400, + 200, + 0, + 0, + "0.40", + 120 + ], + )?; + } + + let stats = db.get_daily_trends(Some(start), Some(end), Some("claude"))?; + assert_eq!(stats.len(), 3); + assert_eq!(stats[0].request_count, 1); + assert_eq!(stats[0].total_tokens, 150); + assert_eq!(stats[1].request_count, 4); + assert_eq!(stats[1].total_tokens, 600); + assert_eq!(stats[2].request_count, 1); + assert_eq!(stats[2].total_tokens, 275); + + Ok(()) + } + + #[test] + fn test_get_model_stats_excludes_partial_rollup_boundary_days() -> Result<(), AppError> { + let db = Database::memory()?; + let start = local_ts(2024, 4, 1, 12, 0, 0); + let end = local_ts(2024, 4, 3, 12, 0, 0); + + { + let conn = lock_conn!(db.conn); + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-04-01", + "claude", + "p1", + "claude-3-haiku", + 6, + 6, + 600, + 300, + 0, + 0, + "0.60", + 100 + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-04-02", + "claude", + "p1", + "claude-3-haiku", + 9, + 8, + 900, + 450, + 0, + 0, + "0.90", + 110 + ], + )?; + conn.execute( + "INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + params![ + "2024-04-03", + "claude", + "p1", + "claude-3-haiku", + 12, + 11, + 1200, + 600, + 0, + 0, + "1.20", + 130 + ], + )?; + } + + let stats = db.get_model_stats(Some(start), Some(end), Some("claude"))?; + assert_eq!(stats.len(), 1); + assert_eq!(stats[0].model, "claude-3-haiku"); + assert_eq!(stats[0].request_count, 9); + assert_eq!(stats[0].total_tokens, 1350); + + Ok(()) + } + #[test] fn test_model_pricing_matching() -> Result<(), AppError> { let db = Database::memory()?; diff --git a/src/components/usage/ModelStatsTable.tsx b/src/components/usage/ModelStatsTable.tsx index 9f629d70b..b74b70f49 100644 --- a/src/components/usage/ModelStatsTable.tsx +++ b/src/components/usage/ModelStatsTable.tsx @@ -9,18 +9,21 @@ import { } from "@/components/ui/table"; import { useModelStats } from "@/lib/query/usage"; import { fmtUsd } from "./format"; +import type { UsageRangeSelection } from "@/types/usage"; interface ModelStatsTableProps { + range: UsageRangeSelection; appType?: string; refreshIntervalMs: number; } export function ModelStatsTable({ + range, appType, refreshIntervalMs, }: ModelStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useModelStats(appType, { + const { data: stats, isLoading } = useModelStats(range, appType, { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }); diff --git a/src/components/usage/ProviderStatsTable.tsx b/src/components/usage/ProviderStatsTable.tsx index 993a436ba..64c18213f 100644 --- a/src/components/usage/ProviderStatsTable.tsx +++ b/src/components/usage/ProviderStatsTable.tsx @@ -9,18 +9,21 @@ import { } from "@/components/ui/table"; import { useProviderStats } from "@/lib/query/usage"; import { fmtUsd } from "./format"; +import type { UsageRangeSelection } from "@/types/usage"; interface ProviderStatsTableProps { + range: UsageRangeSelection; appType?: string; refreshIntervalMs: number; } export function ProviderStatsTable({ + range, appType, refreshIntervalMs, }: ProviderStatsTableProps) { const { t } = useTranslation(); - const { data: stats, isLoading } = useProviderStats(appType, { + const { data: stats, isLoading } = useProviderStats(range, appType, { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }); diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 0b4cd6c51..f1f2835f9 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -17,10 +17,10 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { useRequestLogs, usageKeys } from "@/lib/query/usage"; -import { useQueryClient } from "@tanstack/react-query"; -import type { LogFilters } from "@/types/usage"; -import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react"; +import { useRequestLogs } from "@/lib/query/usage"; +import type { LogFilters, UsageRangeSelection } from "@/types/usage"; +import { ChevronLeft, ChevronRight, Search, X } from "lucide-react"; +import { UsageDateRangePicker } from "./UsageDateRangePicker"; import { fmtInt, fmtUsd, @@ -29,53 +29,28 @@ import { } from "./format"; interface RequestLogTableProps { + range: UsageRangeSelection; + rangeLabel: string; appType?: string; refreshIntervalMs: number; - timeRange?: "1d" | "7d" | "30d"; + onRangeChange?: (range: UsageRangeSelection) => void; } -const ONE_DAY_SECONDS = 24 * 60 * 60; -const MAX_FIXED_RANGE_SECONDS = 30 * ONE_DAY_SECONDS; - -const TIME_RANGE_SECONDS: Record = { - "1d": ONE_DAY_SECONDS, - "7d": 7 * ONE_DAY_SECONDS, - "30d": 30 * ONE_DAY_SECONDS, -}; - -type TimeMode = "rolling" | "fixed"; - export function RequestLogTable({ + range, + rangeLabel, appType: dashboardAppType, refreshIntervalMs, - timeRange = "1d", + onRangeChange, }: RequestLogTableProps) { const { t, i18n } = useTranslation(); - const queryClient = useQueryClient(); - - const rollingWindowSeconds = TIME_RANGE_SECONDS[timeRange] ?? ONE_DAY_SECONDS; - - const getRollingRange = () => { - const now = Math.floor(Date.now() / 1000); - return { startDate: now - rollingWindowSeconds, endDate: now }; - }; - - const [appliedTimeMode, setAppliedTimeMode] = useState("rolling"); - const [draftTimeMode, setDraftTimeMode] = useState("rolling"); const [appliedFilters, setAppliedFilters] = useState({}); const [draftFilters, setDraftFilters] = useState({}); const [page, setPage] = useState(0); const [pageInput, setPageInput] = useState(""); const pageSize = 20; - const [validationError, setValidationError] = useState(null); - // Reset page when the dashboard time range changes - useEffect(() => { - setPage(0); - }, [timeRange]); - - // When dashboard-level app filter is active (not "all"), override the local appType filter const dashboardAppTypeActive = dashboardAppType && dashboardAppType !== "all"; const effectiveFilters: LogFilters = dashboardAppTypeActive ? { ...appliedFilters, appType: dashboardAppType } @@ -83,8 +58,7 @@ export function RequestLogTable({ const { data: result, isLoading } = useRequestLogs({ filters: effectiveFilters, - timeMode: appliedTimeMode, - rollingWindowSeconds, + range, page, pageSize, options: { @@ -96,56 +70,41 @@ export function RequestLogTable({ const total = result?.total ?? 0; const totalPages = Math.ceil(total / pageSize); + useEffect(() => { + setPage(0); + }, [ + dashboardAppType, + range.customEndDate, + range.customStartDate, + range.preset, + ]); + const handleSearch = () => { - setValidationError(null); - - if (draftTimeMode === "fixed") { - const start = draftFilters.startDate; - const end = draftFilters.endDate; - - if (typeof start !== "number" || typeof end !== "number") { - setValidationError( - t("usage.invalidTimeRange", "请选择完整的开始/结束时间"), - ); - return; - } - - if (start > end) { - setValidationError( - t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间"), - ); - return; - } - - if (end - start > MAX_FIXED_RANGE_SECONDS) { - setValidationError( - t("usage.timeRangeTooLarge", "时间范围过大,请缩小范围"), - ); - return; - } - } - - setAppliedTimeMode(draftTimeMode); - setAppliedFilters((prev) => { - const next = { ...prev, ...draftFilters }; - if (draftTimeMode === "rolling") { - delete next.startDate; - delete next.endDate; - } - return next; - }); + setAppliedFilters(draftFilters); setPage(0); }; const handleReset = () => { - setValidationError(null); - setAppliedTimeMode("rolling"); - setDraftTimeMode("rolling"); setDraftFilters({}); setAppliedFilters({}); setPage(0); }; + const applySelectFilter = ( + key: K, + value: LogFilters[K], + ) => { + setDraftFilters((prev) => ({ + ...prev, + [key]: value, + })); + setAppliedFilters((prev) => ({ + ...prev, + [key]: value, + })); + setPage(0); + }; + const handleGoToPage = () => { const trimmed = pageInput.trim(); if (!/^\d+$/.test(trimmed)) return; @@ -155,58 +114,14 @@ export function RequestLogTable({ setPageInput(""); }; - const handleRefresh = () => { - const key = { - timeMode: appliedTimeMode, - rollingWindowSeconds: - appliedTimeMode === "rolling" ? ONE_DAY_SECONDS : undefined, - appType: appliedFilters.appType, - providerName: appliedFilters.providerName, - model: appliedFilters.model, - statusCode: appliedFilters.statusCode, - startDate: - appliedTimeMode === "fixed" ? appliedFilters.startDate : undefined, - endDate: appliedTimeMode === "fixed" ? appliedFilters.endDate : undefined, - }; - - queryClient.invalidateQueries({ - queryKey: usageKeys.logs(key, page, pageSize), - }); - }; - - // 将 Unix 时间戳转换为本地时间的 datetime-local 格式 - const timestampToLocalDatetime = (timestamp: number): string => { - const date = new Date(timestamp * 1000); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - const hours = String(date.getHours()).padStart(2, "0"); - const minutes = String(date.getMinutes()).padStart(2, "0"); - return `${year}-${month}-${day}T${hours}:${minutes}`; - }; - - // 将 datetime-local 格式转换为 Unix 时间戳 - const localDatetimeToTimestamp = (datetime: string): number | undefined => { - if (!datetime) return undefined; - // 验证格式是否完整 (YYYY-MM-DDTHH:mm) - if (datetime.length < 16) return undefined; - const timestamp = new Date(datetime).getTime(); - // 验证是否为有效日期 - if (isNaN(timestamp)) return undefined; - return Math.floor(timestamp / 1000); - }; - const language = i18n.resolvedLanguage || i18n.language || "en"; const locale = getLocaleFromLanguage(language); - const rollingRangeForDisplay = - draftTimeMode === "rolling" ? getRollingRange() : null; - return (
- {/* 筛选栏 */} -
-
+
+
+ {/* App type */} + {/* Status code */} -
-
- - - setDraftFilters({ - ...draftFilters, - providerName: e.target.value || undefined, - }) - } - /> -
+ {/* Provider search */} +
+ + + setDraftFilters({ + ...draftFilters, + providerName: e.target.value || undefined, + }) + } + onKeyDown={(e) => { + if (e.key === "Enter") handleSearch(); + }} + /> +
+ + {/* Model search */} +
setDraftFilters({ @@ -284,89 +202,40 @@ export function RequestLogTable({ model: e.target.value || undefined, }) } - /> -
-
- -
-
- {t("usage.timeRange")}: - { - const timestamp = localDatetimeToTimestamp(e.target.value); - setDraftTimeMode("fixed"); - setDraftFilters({ - ...draftFilters, - startDate: timestamp, - }); - }} - /> - - - { - const timestamp = localDatetimeToTimestamp(e.target.value); - setDraftTimeMode("fixed"); - setDraftFilters({ - ...draftFilters, - endDate: timestamp, - }); + onKeyDown={(e) => { + if (e.key === "Enter") handleSearch(); }} />
-
- - - -
-
+ {onRangeChange && ( + + )} - {validationError && ( -
{validationError}
- )} + {/* Search & Reset (icon-only) */} + + +
{isLoading ? ( @@ -377,40 +246,31 @@ export function RequestLogTable({ - + {t("usage.time")} - + {t("usage.provider")} - + {t("usage.billingModel")} - + {t("usage.inputTokens")} - + {t("usage.outputTokens")} - - {t("usage.cacheReadTokens")} - - - {t("usage.cacheCreationTokens")} - - - {t("usage.multiplier")} - - + {t("usage.totalCost")} - + {t("usage.timingInfo")} - + {t("usage.status")} - + {t("usage.source", { defaultValue: "Source" })} @@ -419,7 +279,7 @@ export function RequestLogTable({ {logs.length === 0 ? ( {t("usage.noData")} @@ -428,140 +288,96 @@ export function RequestLogTable({ ) : ( logs.map((log) => ( - - {new Date(log.createdAt * 1000).toLocaleString(locale)} + + {new Date(log.createdAt * 1000).toLocaleString(locale, { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + })} - + {log.providerName || t("usage.unknownProvider")} - +
- {log.model} + {log.requestModel && + log.requestModel !== log.model ? ( + + {log.requestModel} + + {" → "} + {log.model} + + + ) : ( + log.model + )}
- {log.requestModel && log.requestModel !== log.model && ( -
- ← {log.requestModel} + + +
+ {fmtInt(log.inputTokens, locale)} +
+ {(log.cacheReadTokens > 0 || + log.cacheCreationTokens > 0) && ( +
+ {[ + log.cacheReadTokens > 0 && + `R${fmtInt(log.cacheReadTokens, locale)}`, + log.cacheCreationTokens > 0 && + `W${fmtInt(log.cacheCreationTokens, locale)}`, + ] + .filter(Boolean) + .join("·")}
)}
- - {fmtInt(log.inputTokens, locale)} - - + {fmtInt(log.outputTokens, locale)} - - {fmtInt(log.cacheReadTokens, locale)} + +
+ {fmtUsd(log.totalCostUsd, 4)} +
+ {parseFiniteNumber(log.costMultiplier) != null && + parseFiniteNumber(log.costMultiplier) !== 1 && ( +
+ × + {parseFiniteNumber(log.costMultiplier)?.toFixed( + 2, + )} +
+ )}
- - {fmtInt(log.cacheCreationTokens, locale)} - - - {(parseFiniteNumber(log.costMultiplier) ?? 1) !== 1 ? ( - - ×{log.costMultiplier} + + {(log.latencyMs / 1000).toFixed(1)}s + {log.firstTokenMs != null && ( + + /{(log.firstTokenMs / 1000).toFixed(1)}s - ) : ( - ×1 )} - - {fmtUsd(log.totalCostUsd, 6)} - - -
- {(() => { - const durationMs = - typeof log.durationMs === "number" - ? log.durationMs - : log.latencyMs; - const durationSec = durationMs / 1000; - const durationColor = Number.isFinite(durationSec) - ? durationSec <= 5 - ? "bg-green-100 text-green-800" - : durationSec <= 120 - ? "bg-orange-100 text-orange-800" - : "bg-red-200 text-red-900" - : "bg-gray-100 text-gray-700"; - return ( - - {Number.isFinite(durationSec) - ? `${Math.round(durationSec)}s` - : "--"} - - ); - })()} - {log.isStreaming && - log.firstTokenMs != null && - (() => { - const firstSec = log.firstTokenMs / 1000; - const firstColor = Number.isFinite(firstSec) - ? firstSec <= 5 - ? "bg-green-100 text-green-800" - : firstSec <= 120 - ? "bg-orange-100 text-orange-800" - : "bg-red-200 text-red-900" - : "bg-gray-100 text-gray-700"; - return ( - - {Number.isFinite(firstSec) - ? `${firstSec.toFixed(1)}s` - : "--"} - - ); - })()} - - {log.isStreaming - ? t("usage.stream") - : t("usage.nonStream")} - -
-
- + = 200 && log.statusCode < 300 - ? "bg-green-100 text-green-800" - : "bg-red-100 text-red-800" - }`} + ? "text-green-600" + : "text-red-600" + } > {log.statusCode} - - {log.dataSource && log.dataSource !== "proxy" ? ( - - {t(`usage.dataSource.${log.dataSource}`, { - defaultValue: log.dataSource, - })} - - ) : ( - - {t("usage.dataSource.proxy", { - defaultValue: "Proxy", - })} - - )} + + {log.dataSource || "proxy"} )) @@ -570,89 +386,83 @@ export function RequestLogTable({
- {/* 分页控件 */} - {total > 0 && ( -
- - {t("usage.totalRecords", { total })} - -
- - {(() => { - const pages: (number | string)[] = []; - // 3 head + 3 tail + 3 neighborhood = 9 max distinct pages - if (totalPages <= 9) { - for (let i = 0; i < totalPages; i++) pages.push(i); - } else { - const pageSet = new Set(); - for (let i = 0; i < 3; i++) pageSet.add(i); - for (let i = totalPages - 3; i < totalPages; i++) - pageSet.add(i); - for ( - let i = Math.max(0, page - 1); - i <= Math.min(totalPages - 1, page + 1); - i++ - ) - pageSet.add(i); - const sorted = Array.from(pageSet).sort((a, b) => a - b); - for (let i = 0; i < sorted.length; i++) { - if (i > 0 && sorted[i] - sorted[i - 1] > 1) { - pages.push(`ellipsis-${i}`); - } - pages.push(sorted[i]); +
+ {t("usage.totalRecords", { total })} +
+ + {(() => { + const pages: (number | string)[] = []; + if (totalPages <= 9) { + for (let i = 0; i < totalPages; i++) pages.push(i); + } else { + const pageSet = new Set(); + for (let i = 0; i < 3; i++) pageSet.add(i); + for (let i = totalPages - 3; i < totalPages; i++) + pageSet.add(i); + for ( + let i = Math.max(0, page - 1); + i <= Math.min(totalPages - 1, page + 1); + i++ + ) + pageSet.add(i); + const sorted = Array.from(pageSet).sort((a, b) => a - b); + for (let i = 0; i < sorted.length; i++) { + if (i > 0 && sorted[i] - sorted[i - 1] > 1) { + pages.push(`ellipsis-${i}`); } + pages.push(sorted[i]); } - return pages.map((p) => - typeof p === "string" ? ( - - ... - - ) : ( - - ), - ); - })()} - + ), + ); + })()} + +
+ setPageInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleGoToPage(); + }} + placeholder={t("usage.pageInputPlaceholder")} + className="h-8 w-16 text-center text-xs" + /> + -
- setPageInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") handleGoToPage(); - }} - placeholder={t("usage.pageInputPlaceholder")} - className="h-8 w-16 text-center text-xs" - /> - -
- )} +
)}
diff --git a/src/components/usage/UsageDashboard.tsx b/src/components/usage/UsageDashboard.tsx index 7690279e3..97bc1a80c 100644 --- a/src/components/usage/UsageDashboard.tsx +++ b/src/components/usage/UsageDashboard.tsx @@ -1,12 +1,11 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { UsageSummaryCards } from "./UsageSummaryCards"; import { UsageTrendChart } from "./UsageTrendChart"; import { RequestLogTable } from "./RequestLogTable"; import { ProviderStatsTable } from "./ProviderStatsTable"; import { ModelStatsTable } from "./ModelStatsTable"; -import type { AppTypeFilter, TimeRange } from "@/types/usage"; +import type { AppTypeFilter, UsageRangeSelection } from "@/types/usage"; import { motion } from "framer-motion"; import { BarChart3, @@ -26,6 +25,10 @@ import { } from "@/components/ui/accordion"; import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel"; import { cn } from "@/lib/utils"; +import { getLocaleFromLanguage } from "./format"; +import { getUsageRangePresetLabel, resolveUsageRange } from "@/lib/usageRange"; +import { UsageDateRangePicker } from "./UsageDateRangePicker"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; const APP_FILTER_OPTIONS: AppTypeFilter[] = [ "all", @@ -35,9 +38,9 @@ const APP_FILTER_OPTIONS: AppTypeFilter[] = [ ]; export function UsageDashboard() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const queryClient = useQueryClient(); - const [timeRange, setTimeRange] = useState("1d"); + const [range, setRange] = useState({ preset: "today" }); const [appType, setAppType] = useState("all"); const [refreshIntervalMs, setRefreshIntervalMs] = useState(30000); @@ -46,14 +49,25 @@ export function UsageDashboard() { const currentIndex = refreshIntervalOptionsMs.indexOf( refreshIntervalMs as (typeof refreshIntervalOptionsMs)[number], ); - const safeIndex = currentIndex >= 0 ? currentIndex : 3; // default 30s + const safeIndex = currentIndex >= 0 ? currentIndex : 3; const nextIndex = (safeIndex + 1) % refreshIntervalOptionsMs.length; const next = refreshIntervalOptionsMs[nextIndex]; setRefreshIntervalMs(next); queryClient.invalidateQueries({ queryKey: usageKeys.all }); }; - const days = timeRange === "1d" ? 1 : timeRange === "7d" ? 7 : 30; + const language = i18n.resolvedLanguage || i18n.language || "en"; + const locale = getLocaleFromLanguage(language); + const resolvedRange = useMemo(() => resolveUsageRange(range), [range]); + const rangeLabel = useMemo(() => { + if (range.preset !== "custom") { + return getUsageRangePresetLabel(range.preset, t); + } + + return `${new Date(resolvedRange.startDate * 1000).toLocaleString(locale)} - ${new Date( + resolvedRange.endDate * 1000, + ).toLocaleString(locale)}`; + }, [locale, range, resolvedRange.endDate, resolvedRange.startDate, t]); return ( -
-
-

{t("usage.title")}

-

{t("usage.subtitle")}

+
+
+
+

{t("usage.title")}

+

+ {t("usage.subtitle")} +

+
- setTimeRange(v as TimeRange)} - className="w-full sm:w-auto" - > -
- - - +
+ {APP_FILTER_OPTIONS.map((type) => ( +
- -
+ {t(`usage.appFilter.${type}`)} + + ))} - {/* App type filter bar (replaces DataSourceBar) */} -
-
- {APP_FILTER_OPTIONS.map((type) => ( - - ))} +
+ + + setRange(nextRange)} + /> +
+
@@ -168,14 +166,17 @@ export function UsageDashboard() { > @@ -183,6 +184,7 @@ export function UsageDashboard() { @@ -191,7 +193,6 @@ export function UsageDashboard() {
- {/* Pricing Configuration */} void; + triggerLabel: string; +} + +/* ── helpers ── */ + +function startOfDay(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +function isSameDay(a: Date, b: Date): boolean { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +function toTs(d: Date): number { + return Math.floor(d.getTime() / 1000); +} + +function fromTs(ts: number): Date { + return new Date(ts * 1000); +} + +function fmtDate(ts: number): string { + const d = fromTs(ts); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +function fmtTime(ts: number): string { + const d = fromTs(ts); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + +function parseDateInput(ts: number, value: string): number { + const [y, m, d] = value.split("-").map(Number); + if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) + return ts; + const base = fromTs(ts); + return toTs(new Date(y, m - 1, d, base.getHours(), base.getMinutes())); +} + +function parseTimeInput(ts: number, value: string): number { + const [h, min] = value.split(":").map(Number); + if (!Number.isFinite(h) || !Number.isFinite(min)) return ts; + const base = fromTs(ts); + return toTs( + new Date(base.getFullYear(), base.getMonth(), base.getDate(), h, min), + ); +} + +function setDateKeepTime(ts: number, day: Date): number { + const base = fromTs(ts); + return toTs( + new Date( + day.getFullYear(), + day.getMonth(), + day.getDate(), + base.getHours(), + base.getMinutes(), + ), + ); +} + +function getCalendarDays(month: Date): Date[] { + const first = new Date(month.getFullYear(), month.getMonth(), 1); + const gridStart = new Date(first); + gridStart.setDate(first.getDate() - first.getDay()); + return Array.from({ length: 42 }, (_, i) => { + const d = new Date(gridStart); + d.setDate(gridStart.getDate() + i); + return d; + }); +} + +/* ── component ── */ + +export function UsageDateRangePicker({ + selection, + onApply, + triggerLabel, +}: UsageDateRangePickerProps) { + const { t, i18n } = useTranslation(); + const [open, setOpen] = useState(false); + const [activeField, setActiveField] = useState("start"); + const resolvedRange = useMemo( + () => resolveUsageRange(selection), + [selection], + ); + const [draftStart, setDraftStart] = useState(resolvedRange.startDate); + const [draftEnd, setDraftEnd] = useState(resolvedRange.endDate); + const [displayMonth, setDisplayMonth] = useState( + () => + new Date( + fromTs(resolvedRange.startDate).getFullYear(), + fromTs(resolvedRange.startDate).getMonth(), + 1, + ), + ); + const [error, setError] = useState(null); + + const language = i18n.resolvedLanguage || i18n.language || "en"; + const locale = getLocaleFromLanguage(language); + + // Reset draft when popover opens + useEffect(() => { + if (!open) return; + const r = resolveUsageRange(selection); + setDraftStart(r.startDate); + setDraftEnd(r.endDate); + setDisplayMonth( + new Date( + fromTs(r.startDate).getFullYear(), + fromTs(r.startDate).getMonth(), + 1, + ), + ); + setActiveField("start"); + setError(null); + }, [open, selection]); + + const calendarDays = useMemo( + () => getCalendarDays(displayMonth), + [displayMonth], + ); + + const weekdayLabels = useMemo( + () => + Array.from({ length: 7 }, (_, i) => + new Intl.DateTimeFormat(locale, { weekday: "narrow" }).format( + new Date(2024, 0, 7 + i), + ), + ), + [locale], + ); + + const startDay = fromTs(draftStart); + const endDay = fromTs(draftEnd); + const today = new Date(); + + /* Pick a date from the calendar */ + const handleDatePick = (day: Date) => { + setError(null); + const nextTs = setDateKeepTime( + activeField === "start" ? draftStart : draftEnd, + day, + ); + + if (activeField === "start") { + setDraftStart(nextTs); + // Auto-swap if start > end + if (nextTs > draftEnd) { + setDraftEnd(nextTs); + } + // Auto-advance to end field + setActiveField("end"); + } else { + // If picked end < start, treat as new start and auto-advance + if (nextTs < draftStart) { + setDraftStart(nextTs); + setActiveField("end"); + } else { + setDraftEnd(nextTs); + } + } + + // Navigate calendar if the day is outside the displayed month + if ( + day.getMonth() !== displayMonth.getMonth() || + day.getFullYear() !== displayMonth.getFullYear() + ) { + setDisplayMonth(new Date(day.getFullYear(), day.getMonth(), 1)); + } + }; + + const handleApply = () => { + setError(null); + if (draftStart > draftEnd) { + setError(t("usage.invalidTimeRangeOrder", "开始时间不能晚于结束时间")); + return; + } + onApply({ + preset: "custom", + customStartDate: draftStart, + customEndDate: draftEnd, + }); + setOpen(false); + }; + + const goToToday = () => { + setDisplayMonth(new Date(today.getFullYear(), today.getMonth(), 1)); + }; + + /* ── Field card (start / end) ── */ + const renderField = (field: DraftField) => { + const isActive = activeField === field; + const ts = field === "start" ? draftStart : draftEnd; + const setTs = field === "start" ? setDraftStart : setDraftEnd; + const label = + field === "start" + ? t("usage.startTime", "开始时间") + : t("usage.endTime", "结束时间"); + + return ( +
setActiveField(field)} + > +
+ {label} +
+
+ { + const next = parseDateInput(ts, e.target.value); + setTs(next); + const d = fromTs(next); + setDisplayMonth(new Date(d.getFullYear(), d.getMonth(), 1)); + setError(null); + }} + onFocus={() => setActiveField(field)} + /> + { + setTs(parseTimeInput(ts, e.target.value)); + setError(null); + }} + onFocus={() => setActiveField(field)} + /> +
+
+ ); + }; + + return ( + + + + + + {/* Preset shortcuts */} +
+ {PRESETS.map((preset) => ( + + ))} +
+ +
+ {/* Left: date fields */} +
+

+ {t("usage.customRangeHint", "支持日期与时间,最长 30 天")} +

+ {renderField("start")} + {renderField("end")} + + {error &&

{error}

} + +
+ + +
+
+ + {/* Right: calendar */} +
+ {/* Month navigation */} +
+ + + +
+ + {/* Weekday headers */} +
+ {weekdayLabels.map((label, i) => ( +
+ {label} +
+ ))} +
+ + {/* Day grid */} +
+ {calendarDays.map((day) => { + const isCurrentMonth = + day.getMonth() === displayMonth.getMonth(); + const isToday = isSameDay(day, today); + const isStart = isSameDay(day, startDay); + const isEnd = isSameDay(day, endDay); + const dayStart = startOfDay(day); + const inRange = + dayStart >= startOfDay(startDay) && + dayStart <= startOfDay(endDay); + const isEndpoint = isStart || isEnd; + + return ( + + ); + })} +
+
+
+
+
+ ); +} diff --git a/src/components/usage/UsageSummaryCards.tsx b/src/components/usage/UsageSummaryCards.tsx index 1e7d58f60..5542c8dad 100644 --- a/src/components/usage/UsageSummaryCards.tsx +++ b/src/components/usage/UsageSummaryCards.tsx @@ -5,21 +5,22 @@ import { useUsageSummary } from "@/lib/query/usage"; import { Activity, DollarSign, Layers, Database, Loader2 } from "lucide-react"; import { motion } from "framer-motion"; import { fmtUsd, parseFiniteNumber } from "./format"; +import type { UsageRangeSelection } from "@/types/usage"; interface UsageSummaryCardsProps { - days: number; + range: UsageRangeSelection; appType?: string; refreshIntervalMs: number; } export function UsageSummaryCards({ - days, + range, appType, refreshIntervalMs, }: UsageSummaryCardsProps) { const { t } = useTranslation(); - const { data: summary, isLoading } = useUsageSummary(days, appType, { + const { data: summary, isLoading } = useUsageSummary(range, appType, { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }); diff --git a/src/components/usage/UsageTrendChart.tsx b/src/components/usage/UsageTrendChart.tsx index 7344c62b7..976e3db28 100644 --- a/src/components/usage/UsageTrendChart.tsx +++ b/src/components/usage/UsageTrendChart.tsx @@ -17,20 +17,25 @@ import { getLocaleFromLanguage, parseFiniteNumber, } from "./format"; +import { resolveUsageRange } from "@/lib/usageRange"; +import type { UsageRangeSelection } from "@/types/usage"; interface UsageTrendChartProps { - days: number; + range: UsageRangeSelection; + rangeLabel: string; appType?: string; refreshIntervalMs: number; } export function UsageTrendChart({ - days, + range, + rangeLabel, appType, refreshIntervalMs, }: UsageTrendChartProps) { const { t, i18n } = useTranslation(); - const { data: trends, isLoading } = useUsageTrends(days, appType, { + const { startDate, endDate } = resolveUsageRange(range); + const { data: trends, isLoading } = useUsageTrends(range, appType, { refetchInterval: refreshIntervalMs > 0 ? refreshIntervalMs : false, }); @@ -42,7 +47,8 @@ export function UsageTrendChart({ ); } - const isToday = days === 1; + const durationSeconds = Math.max(endDate - startDate, 0); + const isHourly = durationSeconds <= 24 * 60 * 60; const language = i18n.resolvedLanguage || i18n.language || "en"; const dateLocale = getLocaleFromLanguage(language); const chartData = @@ -51,7 +57,7 @@ export function UsageTrendChart({ const cost = parseFiniteNumber(stat.totalCost); return { rawDate: stat.date, - label: isToday + label: isHourly ? pointDate.toLocaleString(dateLocale, { month: "2-digit", day: "2-digit", @@ -108,13 +114,7 @@ export function UsageTrendChart({

{t("usage.trends", "使用趋势")}

-

- {isToday - ? t("usage.rangeToday", "今天 (按小时)") - : days === 7 - ? t("usage.rangeLast7Days", "过去 7 天") - : t("usage.rangeLast30Days", "过去 30 天")} -

+

{rangeLabel}

diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7cfc3f5a6..eb6cbaf00 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1048,6 +1048,11 @@ "today": "24 Hours", "last7days": "7 Days", "last30days": "30 Days", + "presetToday": "Today", + "preset1d": "1d", + "preset7d": "7d", + "preset14d": "14d", + "preset30d": "30d", "totalRequests": "Total Requests", "totalCost": "Total Cost", "cost": "Cost", @@ -1139,6 +1144,10 @@ "searchProviderPlaceholder": "Search provider...", "searchModelPlaceholder": "Search model...", "timeRange": "Time Range", + "customRange": "Calendar Filter", + "customRangeHint": "Supports both date and time", + "startTime": "Start Time", + "endTime": "End Time", "input": "Input", "output": "Output", "cacheWrite": "Creation", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 2d867c394..c184c167d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1048,6 +1048,11 @@ "today": "24時間", "last7days": "7日間", "last30days": "30日間", + "presetToday": "当日", + "preset1d": "1d", + "preset7d": "7d", + "preset14d": "14d", + "preset30d": "30d", "totalRequests": "総リクエスト数", "totalCost": "総コスト", "cost": "コスト", @@ -1139,6 +1144,10 @@ "searchProviderPlaceholder": "プロバイダーを検索...", "searchModelPlaceholder": "モデルを検索...", "timeRange": "期間", + "customRange": "カレンダーフィルター", + "customRangeHint": "日付と時刻の両方に対応", + "startTime": "開始時刻", + "endTime": "終了時刻", "input": "Input", "output": "Output", "cacheWrite": "作成", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 3a5069b95..faaeaad7f 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1049,6 +1049,11 @@ "today": "24小时", "last7days": "7天", "last30days": "30天", + "presetToday": "当天", + "preset1d": "1d", + "preset7d": "7d", + "preset14d": "14d", + "preset30d": "30d", "totalRequests": "总请求数", "totalCost": "总成本", "cost": "成本", @@ -1140,6 +1145,10 @@ "searchProviderPlaceholder": "搜索供应商...", "searchModelPlaceholder": "搜索模型...", "timeRange": "时间范围", + "customRange": "日历筛选", + "customRangeHint": "支持日期与时间", + "startTime": "开始时间", + "endTime": "结束时间", "input": "Input", "output": "Output", "cacheWrite": "创建", diff --git a/src/lib/api/usage.ts b/src/lib/api/usage.ts index 1ff974705..33ef3c9a0 100644 --- a/src/lib/api/usage.ts +++ b/src/lib/api/usage.ts @@ -63,12 +63,20 @@ export const usageApi = { return invoke("get_usage_trends", { startDate, endDate, appType }); }, - getProviderStats: async (appType?: string): Promise => { - return invoke("get_provider_stats", { appType }); + getProviderStats: async ( + startDate?: number, + endDate?: number, + appType?: string, + ): Promise => { + return invoke("get_provider_stats", { startDate, endDate, appType }); }, - getModelStats: async (appType?: string): Promise => { - return invoke("get_model_stats", { appType }); + getModelStats: async ( + startDate?: number, + endDate?: number, + appType?: string, + ): Promise => { + return invoke("get_model_stats", { startDate, endDate, appType }); }, getRequestLogs: async ( diff --git a/src/lib/query/usage.ts b/src/lib/query/usage.ts index 84babb1a3..ed565508d 100644 --- a/src/lib/query/usage.ts +++ b/src/lib/query/usage.ts @@ -1,6 +1,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { usageApi } from "@/lib/api/usage"; -import type { LogFilters } from "@/types/usage"; +import { resolveUsageRange } from "@/lib/usageRange"; +import type { LogFilters, UsageRangeSelection } from "@/types/usage"; const DEFAULT_REFETCH_INTERVAL_MS = 30000; @@ -9,51 +10,94 @@ type UsageQueryOptions = { refetchIntervalInBackground?: boolean; }; -type RequestLogsTimeMode = "rolling" | "fixed"; - type RequestLogsQueryArgs = { filters: LogFilters; - timeMode: RequestLogsTimeMode; + range: UsageRangeSelection; page?: number; pageSize?: number; - rollingWindowSeconds?: number; options?: UsageQueryOptions; }; type RequestLogsKey = { - timeMode: RequestLogsTimeMode; - rollingWindowSeconds?: number; + preset: UsageRangeSelection["preset"]; + customStartDate?: number; + customEndDate?: number; appType?: string; providerName?: string; model?: string; statusCode?: number; - startDate?: number; - endDate?: number; }; // Query keys export const usageKeys = { all: ["usage"] as const, - summary: (days: number, appType?: string) => - [...usageKeys.all, "summary", days, appType ?? "all"] as const, - trends: (days: number, appType?: string) => - [...usageKeys.all, "trends", days, appType ?? "all"] as const, - providerStats: (appType?: string) => - [...usageKeys.all, "provider-stats", appType ?? "all"] as const, - modelStats: (appType?: string) => - [...usageKeys.all, "model-stats", appType ?? "all"] as const, + summary: ( + preset: UsageRangeSelection["preset"], + customStartDate: number | undefined, + customEndDate: number | undefined, + appType?: string, + ) => + [ + ...usageKeys.all, + "summary", + preset, + customStartDate ?? 0, + customEndDate ?? 0, + appType ?? "all", + ] as const, + trends: ( + preset: UsageRangeSelection["preset"], + customStartDate: number | undefined, + customEndDate: number | undefined, + appType?: string, + ) => + [ + ...usageKeys.all, + "trends", + preset, + customStartDate ?? 0, + customEndDate ?? 0, + appType ?? "all", + ] as const, + providerStats: ( + preset: UsageRangeSelection["preset"], + customStartDate: number | undefined, + customEndDate: number | undefined, + appType?: string, + ) => + [ + ...usageKeys.all, + "provider-stats", + preset, + customStartDate ?? 0, + customEndDate ?? 0, + appType ?? "all", + ] as const, + modelStats: ( + preset: UsageRangeSelection["preset"], + customStartDate: number | undefined, + customEndDate: number | undefined, + appType?: string, + ) => + [ + ...usageKeys.all, + "model-stats", + preset, + customStartDate ?? 0, + customEndDate ?? 0, + appType ?? "all", + ] as const, logs: (key: RequestLogsKey, page: number, pageSize: number) => [ ...usageKeys.all, "logs", - key.timeMode, - key.rollingWindowSeconds ?? 0, + key.preset, + key.customStartDate ?? 0, + key.customEndDate ?? 0, key.appType ?? "", key.providerName ?? "", key.model ?? "", key.statusCode ?? -1, - key.startDate ?? 0, - key.endDate ?? 0, page, pageSize, ] as const, @@ -64,23 +108,22 @@ export const usageKeys = { [...usageKeys.all, "limits", providerId, appType] as const, }; -const getWindow = (days: number) => { - const endDate = Math.floor(Date.now() / 1000); - const startDate = endDate - days * 24 * 60 * 60; - return { startDate, endDate }; -}; - // Hooks export function useUsageSummary( - days: number, + range: UsageRangeSelection, appType?: string, options?: UsageQueryOptions, ) { const effectiveAppType = appType === "all" ? undefined : appType; return useQuery({ - queryKey: usageKeys.summary(days, appType), + queryKey: usageKeys.summary( + range.preset, + range.customStartDate, + range.customEndDate, + appType, + ), queryFn: () => { - const { startDate, endDate } = getWindow(days); + const { startDate, endDate } = resolveUsageRange(range); return usageApi.getUsageSummary(startDate, endDate, effectiveAppType); }, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, @@ -89,15 +132,20 @@ export function useUsageSummary( } export function useUsageTrends( - days: number, + range: UsageRangeSelection, appType?: string, options?: UsageQueryOptions, ) { const effectiveAppType = appType === "all" ? undefined : appType; return useQuery({ - queryKey: usageKeys.trends(days, appType), + queryKey: usageKeys.trends( + range.preset, + range.customStartDate, + range.customEndDate, + appType, + ), queryFn: () => { - const { startDate, endDate } = getWindow(days); + const { startDate, endDate } = resolveUsageRange(range); return usageApi.getUsageTrends(startDate, endDate, effectiveAppType); }, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, @@ -106,61 +154,70 @@ export function useUsageTrends( } export function useProviderStats( + range: UsageRangeSelection, appType?: string, options?: UsageQueryOptions, ) { const effectiveAppType = appType === "all" ? undefined : appType; return useQuery({ - queryKey: usageKeys.providerStats(appType), - queryFn: () => usageApi.getProviderStats(effectiveAppType), + queryKey: usageKeys.providerStats( + range.preset, + range.customStartDate, + range.customEndDate, + appType, + ), + queryFn: () => { + const { startDate, endDate } = resolveUsageRange(range); + return usageApi.getProviderStats(startDate, endDate, effectiveAppType); + }, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, }); } -export function useModelStats(appType?: string, options?: UsageQueryOptions) { +export function useModelStats( + range: UsageRangeSelection, + appType?: string, + options?: UsageQueryOptions, +) { const effectiveAppType = appType === "all" ? undefined : appType; return useQuery({ - queryKey: usageKeys.modelStats(appType), - queryFn: () => usageApi.getModelStats(effectiveAppType), + queryKey: usageKeys.modelStats( + range.preset, + range.customStartDate, + range.customEndDate, + appType, + ), + queryFn: () => { + const { startDate, endDate } = resolveUsageRange(range); + return usageApi.getModelStats(startDate, endDate, effectiveAppType); + }, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, refetchIntervalInBackground: options?.refetchIntervalInBackground ?? false, }); } -const getRollingRange = (windowSeconds: number) => { - const endDate = Math.floor(Date.now() / 1000); - const startDate = endDate - windowSeconds; - return { startDate, endDate }; -}; - export function useRequestLogs({ filters, - timeMode, + range, page = 0, pageSize = 20, - rollingWindowSeconds = 24 * 60 * 60, options, }: RequestLogsQueryArgs) { const key: RequestLogsKey = { - timeMode, - rollingWindowSeconds: - timeMode === "rolling" ? rollingWindowSeconds : undefined, + preset: range.preset, + customStartDate: range.customStartDate, + customEndDate: range.customEndDate, appType: filters.appType, providerName: filters.providerName, model: filters.model, statusCode: filters.statusCode, - startDate: timeMode === "fixed" ? filters.startDate : undefined, - endDate: timeMode === "fixed" ? filters.endDate : undefined, }; return useQuery({ queryKey: usageKeys.logs(key, page, pageSize), queryFn: () => { - const effectiveFilters = - timeMode === "rolling" - ? { ...filters, ...getRollingRange(rollingWindowSeconds) } - : filters; + const effectiveFilters = { ...filters, ...resolveUsageRange(range) }; return usageApi.getRequestLogs(effectiveFilters, page, pageSize); }, refetchInterval: options?.refetchInterval ?? DEFAULT_REFETCH_INTERVAL_MS, // 每30秒自动刷新 diff --git a/src/lib/usageRange.ts b/src/lib/usageRange.ts new file mode 100644 index 000000000..4b70ee1d2 --- /dev/null +++ b/src/lib/usageRange.ts @@ -0,0 +1,79 @@ +import type { UsageRangePreset, UsageRangeSelection } from "@/types/usage"; + +const DAY_SECONDS = 24 * 60 * 60; +const DAY_MS = DAY_SECONDS * 1000; + +export interface ResolvedUsageRange { + startDate: number; + endDate: number; +} + +function getStartOfLocalDayDate(nowMs: number): Date { + const date = new Date(nowMs); + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +function getPresetLookbackStart( + preset: Exclude, + nowMs: number, +): number { + const dayCount = preset === "7d" ? 7 : preset === "14d" ? 14 : 30; + return Math.floor( + getStartOfLocalDayDate(nowMs - (dayCount - 1) * DAY_MS).getTime() / 1000, + ); +} + +export function resolveUsageRange( + selection: UsageRangeSelection, + nowMs: number = Date.now(), +): ResolvedUsageRange { + const endDate = Math.floor(nowMs / 1000); + + switch (selection.preset) { + case "today": + return { + startDate: Math.floor(getStartOfLocalDayDate(nowMs).getTime() / 1000), + endDate, + }; + case "1d": + return { + startDate: endDate - DAY_SECONDS, + endDate, + }; + case "7d": + case "14d": + case "30d": + return { + startDate: getPresetLookbackStart(selection.preset, nowMs), + endDate, + }; + case "custom": { + const startDate = selection.customStartDate ?? endDate - DAY_SECONDS; + const customEndDate = selection.customEndDate ?? endDate; + return { + startDate, + endDate: customEndDate, + }; + } + } +} + +export function getUsageRangePresetLabel( + preset: UsageRangePreset, + t: (key: string, options?: { defaultValue?: string }) => string, +): string { + switch (preset) { + case "today": + return t("usage.presetToday", { defaultValue: "当天" }); + case "1d": + return t("usage.preset1d", { defaultValue: "1d" }); + case "7d": + return t("usage.preset7d", { defaultValue: "7d" }); + case "14d": + return t("usage.preset14d", { defaultValue: "14d" }); + case "30d": + return t("usage.preset30d", { defaultValue: "30d" }); + case "custom": + return t("usage.customRange", { defaultValue: "日历筛选" }); + } +} diff --git a/src/types/usage.ts b/src/types/usage.ts index ca9b40a09..c595c1cf7 100644 --- a/src/types/usage.ts +++ b/src/types/usage.ts @@ -121,12 +121,18 @@ export interface ProviderLimitStatus { monthlyExceeded: boolean; } -export type TimeRange = "1d" | "7d" | "30d"; +export type UsageRangePreset = "today" | "1d" | "7d" | "14d" | "30d" | "custom"; + +export interface UsageRangeSelection { + preset: UsageRangePreset; + customStartDate?: number; + customEndDate?: number; +} export type AppTypeFilter = "all" | "claude" | "codex" | "gemini"; export interface StatsFilters { - timeRange: TimeRange; + timeRange: UsageRangePreset; providerId?: string; appType?: string; } diff --git a/tests/components/RequestLogTable.test.tsx b/tests/components/RequestLogTable.test.tsx new file mode 100644 index 000000000..62c5178be --- /dev/null +++ b/tests/components/RequestLogTable.test.tsx @@ -0,0 +1,161 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RequestLogTable } from "@/components/usage/RequestLogTable"; +import type { UsageRangeSelection } from "@/types/usage"; + +const useRequestLogsMock = vi.hoisted(() => vi.fn()); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: ( + key: string, + options?: { + defaultValue?: string; + }, + ) => options?.defaultValue ?? key, + i18n: { + resolvedLanguage: "en", + language: "en", + }, + }), +})); + +vi.mock("@/lib/query/usage", () => ({ + useRequestLogs: (args: unknown) => useRequestLogsMock(args), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, ...props }: any) => ( + + ), +})); + +vi.mock("@/components/ui/input", () => ({ + Input: (props: any) => , +})); + +vi.mock("@/components/ui/select", () => ({ + Select: ({ children }: any) =>
{children}
, + SelectTrigger: ({ children, ...props }: any) => ( + + ), + SelectValue: ({ placeholder }: any) => {placeholder ?? null}, + SelectContent: () => null, + SelectItem: () => null, +})); + +vi.mock("@/components/ui/table", () => ({ + Table: ({ children }: any) => {children}
, + TableBody: ({ children }: any) => {children}, + TableCell: ({ children, ...props }: any) => {children}, + TableHead: ({ children, ...props }: any) => {children}, + TableHeader: ({ children }: any) => {children}, + TableRow: ({ children }: any) => {children}, +})); + +describe("RequestLogTable", () => { + beforeEach(() => { + useRequestLogsMock.mockReset(); + useRequestLogsMock.mockImplementation( + ({ page = 0, pageSize = 20 }: { page?: number; pageSize?: number }) => ({ + data: { + data: [], + total: 120, + page, + pageSize, + }, + isLoading: false, + }), + ); + }); + + it("resets pagination when the dashboard range changes", async () => { + const initialRange: UsageRangeSelection = { preset: "today" }; + const nextRange: UsageRangeSelection = { + preset: "custom", + customStartDate: 1_710_000_000, + customEndDate: 1_710_086_400, + }; + + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "2" })); + + await waitFor(() => { + expect(useRequestLogsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + page: 1, + range: initialRange, + }), + ); + }); + + rerender( + , + ); + + await waitFor(() => { + expect(useRequestLogsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + page: 0, + range: nextRange, + }), + ); + }); + }); + + it("resets pagination when the dashboard app filter changes", async () => { + const range: UsageRangeSelection = { preset: "today" }; + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "2" })); + + await waitFor(() => { + expect(useRequestLogsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + page: 1, + range, + }), + ); + }); + + rerender( + , + ); + + await waitFor(() => { + expect(useRequestLogsMock).toHaveBeenLastCalledWith( + expect.objectContaining({ + page: 0, + range, + }), + ); + }); + }); +}); From 03a0f9661bac3ede3597a27b34336ded1df1b40f Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Thu, 16 Apr 2026 22:42:49 +0800 Subject: [PATCH 4/9] feat(proxy): Gemini Native API proxy integration (#1918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(proxy): extract take_sse_block helper with CRLF delimiter support Replace inline `buffer.find("\n\n")` SSE splitting logic across streaming, streaming_responses, response_handler, and response_processor with a shared `take_sse_block` function that handles both `\n\n` and `\r\n\r\n` delimiters. * feat(proxy): add Gemini Native URL builder and full-URL resolver Introduce gemini_url module that normalizes legacy Gemini/OpenAI-compatible base URLs into canonical models/*:generateContent endpoints. Supports both structured Gemini URLs (auto-normalized) and opaque relay URLs (pass-through with query params only). * feat(proxy): add Gemini Native schema, shadow store, transform, and streaming - gemini_schema: Gemini generateContent request/response type definitions - gemini_shadow: session-scoped shadow store for thinking signature and tool-call state replay across streaming chunks - transform_gemini: bidirectional Anthropic Messages ↔ Gemini Native request/response conversion with thinking block and tool-use support - streaming_gemini: Gemini SSE → Anthropic SSE streaming adapter with incremental thinking/text/tool_use delta emission * feat(proxy): wire Gemini Native format into proxy core and Claude adapter Integrate gemini_native api_format throughout the proxy pipeline: - ClaudeAdapter: detect Gemini provider type, Google/GoogleOAuth auth strategies, and suppress Anthropic-specific headers for Gemini targets - Forwarder: Gemini URL resolution, shadow store threading, endpoint rewriting to models/*:generateContent with stream/non-stream variants - Handlers: route Gemini streaming through streaming_gemini adapter and non-streaming through transform_gemini converter - Server/State: add GeminiShadowStore to shared ProxyState - StreamCheck: support gemini_native health check with proper auth headers * feat(ui): add Gemini Native provider preset and api format option - Add gemini_native to ClaudeApiFormat type and ProviderMeta.apiFormat - Add "Gemini Native" provider preset with default Google AI endpoints - Show Gemini-specific endpoint hints and full-URL mode guidance - Add gemini_native option to API format selector in ClaudeFormFields - Add i18n strings for zh/en/ja * feat(proxy): add Gemini Native tool argument rectification * feat(proxy): update Gemini streaming and transformation logic * fix(proxy): align shadow turns to tail on client history truncation * fix: revert unrelated cache_key change in claude proxy transform Restore .unwrap_or(&provider.id) fallback for cache_key to match main branch behavior. Only gemini_native related changes should be in this branch. * Prevent Gemini review regressions in streaming and tool rectification PR #1918 review feedback exposed two correctness issues in the Gemini Native adapter path. Gemini SSE buffering was still using lossy UTF-8 decoding, which could corrupt split multibyte payloads and drop streamed output. Tool arg rectification also removed top-level parameters eagerly, which broke tools that legitimately define a parameters field. This change moves Gemini SSE buffering onto the existing append_utf8_safe path and makes parameters flattening conditional on the schema actually expecting nested extraction. The old Skill rectification path stays intact, and new regression tests cover both the preserved parameters case and UTF-8-split JSON payloads. Constraint: Existing PR #1918 review feedback must be fixed without staging unrelated local docs and artifact files Rejected: Keep String::from_utf8_lossy in Gemini SSE buffering | corrupts split multibyte payloads and can drop JSON chunks Rejected: Always preserve the parameters wrapper | regresses the existing nested-parameters rectification path for Skill-style tools Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep Gemini SSE buffering on the UTF-8-safe accumulator path and only unwrap parameters when the target schema does not declare it as a legitimate field Tested: cargo fmt --manifest-path src-tauri/Cargo.toml --all; cargo test --manifest-path src-tauri/Cargo.toml preserves_utf8_boundaries_when_json_payload_spans_chunks; cargo test --manifest-path src-tauri/Cargo.toml gemini_to_anthropic_rectifies_tool_args_from_schema_hints; cargo test --manifest-path src-tauri/Cargo.toml rectifies_streamed_skill_args_from_nested_parameters; cargo test --manifest-path src-tauri/Cargo.toml gemini_to_anthropic_preserves_legitimate_parameters_arg Not-tested: Full src-tauri test suite; live end-to-end Gemini relay traffic against upstream services * Keep Gemini tool replay stable across Claude request boundaries Claude Code follow-up requests were still falling back to locally reconstructed functionCall parts, which dropped Gemini thought signatures and triggered INVALID_ARGUMENT errors from the official Gemini API. The replay path needed to survive real Claude request boundaries, not just idealized in-process test flows. This change makes Claude requests reuse X-Claude-Code-Session-Id as the shadow session key, records streamed Gemini tool turns before tool_use events are fully drained, and matches assistant tool_use turns to shadow state by tool_use id and normalized tool name before positional fallback. Together these fixes keep thoughtSignature-bearing Gemini tool calls available for the next request in the loop. Constraint: Claude Code sends a stable X-Claude-Code-Session-Id header while metadata.session_id may be absent on follow-up requests Rejected: Rely on metadata-only Claude session extraction | generated fresh session ids and broke cross-request shadow replay Rejected: Record Gemini shadow only after streaming completes | loses the race when the client sends the next request immediately after tool_use Confidence: high Scope-risk: narrow Reversibility: clean Directive: Preserve Gemini shadow continuity across requests by keying Claude sessions from the header first and persisting tool-call shadow before yielding tool_use events downstream Tested: cargo fmt --manifest-path src-tauri/Cargo.toml --all; cargo test --manifest-path src-tauri/Cargo.toml test_extract_session_from_claude_header; cargo test --manifest-path src-tauri/Cargo.toml test_extract_session_from_claude_header_precedes_metadata; cargo test --manifest-path src-tauri/Cargo.toml stores_tool_shadow_before_tool_use_events_are_fully_drained; cargo test --manifest-path src-tauri/Cargo.toml shadow_replay_matches_tool_use_turn_by_id_when_position_drifts; cargo test --manifest-path src-tauri/Cargo.toml shadow_replay_aligns_to_latest_turns_after_client_truncation Not-tested: Full src-tauri test suite without test filters; live end-to-end Gemini relay after this exact commit hash * style: apply cargo fmt to pass Backend Checks CI Wrap prompt_cache_key chained call across lines per rustfmt default formatting. Pure formatting change, no behavior difference. * fix(proxy/gemini): synthesize unique ids for no-id tool calls + enforce object params schema P1 — Parallel tool calls without Gemini-assigned ids no longer collapse. Gemini 2.x native parallel `functionCall` entries may omit the `id` field. The previous `merge_tool_call_snapshots` fell back to matching by `name`, which silently merged two parallel calls to the same function into one entry — dropping the first call's args. The non-streaming path and shadow store further bottlenecked on empty-string ids: multiple `tool_use` blocks shared the same id, and `tool_name_by_id.get("")` could only return one mapping, causing later `tool_result` round-trips to fail with `Unable to resolve Gemini functionResponse.name` or bind to the wrong tool. Fix: introduce `synthesize_tool_call_id()` producing `gemini_synth_`. Both streaming and non-streaming response paths now guarantee every Anthropic-visible tool_use carries a unique id. `merge_tool_call_snapshots` matches by id first, falling back to the `parts` array position (for the cumulative-streaming case) while preserving the synthesized id across chunks. `convert_message_content_to_parts` detects the synthetic prefix and strips the id from outbound `functionCall`/`functionResponse` so the internal identifier never leaks upstream. `shadow_parts` performs the same strip when replaying a recorded assistant turn. P2 — Vertex AI rejects empty `parameters` schemas. When an Anthropic tool arrives with missing or empty `input_schema`, the proxy used to emit `"parameters": {}` (no `type`), which fails Vertex AI validation with `functionDeclaration parameters schema should be of type OBJECT`. Contrary to the automated-review suggestion, the fix is not to omit `parameters` (that too is rejected) but to normalize to the canonical empty-object form `{type: "object", properties: {}}`. Refs: google-gemini/generative-ai-python#423, BerriAI/litellm#5055. Fix: new `ensure_object_schema` helper in `gemini_schema` promotes missing `type` to `"object"` and adds empty `properties` when absent, while leaving atomic (non-object) schemas untouched. Tests: seven new regressions covering parallel no-id calls, cumulative chunk id reuse, synthetic-id round-trip both directions, shadow replay id stripping, and the three Vertex-AI schema shapes. The two existing wrapper functions (`gemini_to_anthropic` and `gemini_to_anthropic_with_shadow`) gain `#[allow(dead_code)]` to clear a pre-existing clippy -D warnings failure — they are part of the public transform API surface and intentionally kept for future callers. Addresses Codex review P1/P2 on #1918. * fix(proxy/gemini): narrow URL normalization + guard empty OAuth access_token P2a — Preserve opaque relay URLs that contain `/v1/models/` prefixes. `should_normalize_gemini_full_url` previously flagged any full URL whose path merely contained `/v1beta/models/` or `/v1/models/` as a structured Gemini endpoint, forcing rewrite to `.../v1beta/models/{model}:method`. This silently dropped legitimate relay route segments (e.g. `https://relay.example/v1/models/invoke` → `.../v1beta/models/...:generateContent`, losing `/invoke`) and sent traffic to the wrong upstream path. Replace the bare `contains(...)` checks with `matches_structured_gemini_models_path`, which requires the `/models/` segment to be followed by a canonical Gemini method call (`*:generateContent` or `*:streamGenerateContent`). The `matches_bare_gemini_models_path` helper is generalized (and renamed) to handle both `/v1beta/models/` and `/v1/models/` alongside the original bare `/models/` shape. P2b — Reject empty Gemini OAuth access_tokens before they reach the bearer header. `GeminiAdapter::parse_oauth_credentials` accepts refresh-token-only JSON (and surfaces `{"access_token": "", ...}` for expired credentials) with `access_token` defaulting to `""`. The Claude adapter's GeminiCli branch then called `AuthInfo::with_access_token(key, creds.access_token)` unconditionally, so the bearer-header builder at `AuthStrategy::GoogleOAuth` resolved to `Authorization: Bearer ` — a deterministic 401 from upstream. CC Switch does not currently exchange the refresh_token for a fresh access_token (`OAuthCredentials::needs_refresh` / `can_refresh` are annotated `#[allow(dead_code)]`). Until that exists, only attach `access_token` when it is non-empty; fall back to plain GoogleOAuth strategy with the raw key and log a warn pointing users at `~/.gemini/oauth_creds.json` so the failure mode is observable. Tests: - gemini_url.rs: three new regressions — opaque `/v1/models/invoke`, opaque `/v1beta/models/route`, and the positive counter-case where a structured `/v1/models/...:generateContent` path still normalizes. - claude.rs: three new `test_extract_auth_gemini_cli_*` tests covering refresh-only JSON, empty-string access_token JSON, and the valid-JSON pass-through. All 839 lib tests pass; cargo fmt + clippy -D warnings clean. Addresses Codex review P2 findings on #1918. * fix(proxy/gemini): treat empty-string functionCall id as missing in streaming path Follow-up to the earlier P1 fix: some Gemini relays serialize an absent functionCall id as `"id": ""` instead of omitting the field. The non-streaming `extract_tool_call_meta` already filters these via `.filter(|s| !s.is_empty())`, but the streaming counterpart `extract_tool_calls` passed the empty string straight through `function_call.get("id").and_then(|v| v.as_str())` into `GeminiToolCallMeta::new`, producing a `Some("")` id. Downstream, `merge_tool_call_snapshots` would then match two parallel no-id calls against each other on their shared empty-string id, collapsing them into a single snapshot (silent data loss for the first call) and emitting an Anthropic `tool_use.id: ""` that breaks tool_result correlation on the Claude Code client. Fix: - `extract_tool_calls`: apply the same `filter(|s| !s.is_empty())` guard used in the non-streaming path so empty strings become `None` before reaching the shadow meta. - `merge_tool_call_snapshots`: defensively collapse any incoming `Some("")` to `None` up front — keeps the "missing vs present" invariant local to the merge step for future callers that might build `GeminiToolCallMeta` by hand. Tests (2 new, both in streaming_gemini): - `parallel_empty_string_id_calls_are_treated_as_missing_and_preserved` covers two parallel calls with explicit `"id": ""` — asserts both surface, no empty tool_use id leaks, and each gets a unique `gemini_synth_` id. - `single_empty_string_id_tool_call_gets_synthesized_id` covers the non-parallel degraded-relay case. All 841 lib tests pass; cargo fmt + clippy -D warnings clean. Addresses Codex follow-up P1 on #1918. * fix(proxy/gemini): gate generic REST path suffixes behind Google host whitelist `should_normalize_gemini_full_url` previously treated any full URL whose path ends with `/v1`, `/v1/models`, `/models`, `/v1/openai`, or `/openai` as a structured Gemini endpoint and rewrote it to `/v1beta/models/{model}:generateContent`. These are ubiquitous REST conventions — opaque relays such as `https://relay.example/custom/v1` legitimately use them for fixed endpoints — so the rewrite silently routed traffic to the wrong upstream path. Split the predicate into two layers: - **Unconditional**: `matches_structured_gemini_models_path` (i.e. a `/models/...:generateContent` method call anywhere in the path), the Google-specific `/v1beta*` family, and the deep OpenAI-compat paths (`/v1beta/openai/chat/completions`, `/openai/chat/completions`, and their `responses` siblings). These remain host-agnostic because the path grammar itself is Gemini-specific. - **Google-host gated**: `/v1`, `/v1/models`, `/models`, `/v1/openai`, `/openai`. Only normalized when the host is one of `generativelanguage.googleapis.com`, `aiplatform.googleapis.com`, or a real `*-aiplatform.googleapis.com` Vertex regional endpoint. The match is exact/suffix (not `contains`), so lookalike hosts like `aiplatform.example.com` are correctly treated as opaque relays. Tests (8 new in `gemini_url::tests`): - Four opaque-relay cases: `/custom/v1`, `/custom/models`, `/custom/v1/models`, `/custom/openai` — all preserved as-is. - Three Google-host counter-cases: `/v1`, `/models`, and `us-central1-aiplatform.googleapis.com/v1` still normalize. - One lookalike safety case: `aiplatform.example.com/v1` is NOT treated as Google. All 849 lib tests pass; cargo fmt + clippy -D warnings clean. Addresses Codex review P2 on #1918. * fix(proxy/gemini): align shadow id with client-visible id in non-streaming path When Gemini returns a `functionCall` without an id (common in 2.x parallel calls), `gemini_to_anthropic_with_shadow_and_hints` previously generated TWO independent synthesized UUIDs: 1. Line 186-197 — synthesized id `A` used for the Anthropic-visible `content[tool_use].id` returned to the client. 2. Line 850-881 — `extract_tool_call_meta` independently synthesized id `B ≠ A`, which populated `shadow_turn.tool_calls[i].id`. `shadow_content` (line 225-228, cloned from `rectified_parts`) retained the original missing/empty id. Result: the client sees id `A`, the shadow store holds id `B`. On the next turn, `convert_messages_to_contents` builds `tool_name_by_id` from `build_tool_name_map_from_shadow_turns`, which uses `tool_calls[i].id` — so the map contains `B → name` but not `A → name`. When the client sends back `tool_result(tool_use_id=A)`, resolution fails with: Unable to resolve Gemini functionResponse.name for tool_use_id `A` This affects both truncated histories (client sends only the tool_result) and full histories (shadow-replay branch at line 342-354 skips `convert_message_content_to_parts`, so the assistant tool_use block never registers id `A` itself). Fix: make `rectified_parts` the single source of truth. After `rectify_tool_call_parts`, run a pre-pass that writes `synthesize_tool_call_id()` back into any `functionCall` that lacks a non-empty id. All three readers — the content builder (186-197), the shadow_content clone (225-228), and `extract_tool_call_meta` — then observe the same id. `shadow_parts()` already strips synthesized ids on replay (line 616-628), so the internal identifier never leaks to Gemini upstream. This mirrors the streaming path, which already has single-source-of- truth semantics via `tool_call_snapshots` in `streaming_gemini.rs` — no change needed there. Tests (5 new in `transform_gemini::tests`): - `non_stream_shadow_id_matches_client_visible_id`: asserts `response.content[0].id == shadow.tool_calls[0].id == shadow.assistant_content.parts[0].functionCall.id`. - `non_stream_missing_id_scenario_a_truncated_history_resolves`: turn 2 sends only `[tool_result(id=A)]`; resolution must succeed. - `non_stream_missing_id_scenario_b_full_history_replay_resolves`: turn 2 sends `[assistant(tool_use=A), tool_result(A)]`; shadow-replay branch strips the synth id from outgoing `functionCall` while still resolving the subsequent `tool_result`. - `non_stream_preserves_original_gemini_id_when_present`: regression — genuine Gemini ids flow through unchanged. - `non_stream_synthesized_id_not_leaked_to_gemini_via_shadow_replay`: defensive — shadow-replay path must strip synth ids from both `functionCall.id` and `functionResponse.id`. All 854 lib tests pass; cargo fmt + clippy -D warnings clean. Addresses Codex follow-up P1 on #1918. * refactor(proxy/gemini): share build_anthropic_usage between stream and non-stream paths `streaming_gemini::anthropic_usage_from_gemini` and `transform_gemini::build_anthropic_usage` were byte-for-byte identical (32 lines each) — both converting Gemini `usageMetadata` into the Anthropic `usage` shape including `cache_read_input_tokens` mapping. Promote the non-streaming version to `pub(crate)` and reuse it from the streaming SSE converter. Removes ~30 lines of duplication and guarantees the two paths cannot drift apart. No behavioral change; all 854 lib tests pass; cargo fmt + clippy -D warnings clean. * fix(proxy/gemini): gate /v1beta behind Google host + normalize models/ model id prefix Two related P2 corrections to the Gemini Native URL surface, both folding into the existing Google-host-whitelist architecture. ## P2a — `/v1beta` suffix should not unconditionally trigger rewrite `should_normalize_gemini_full_url` placed `/v1beta` and `/v1beta/models` in the unconditional layer on the reasoning that `/v1beta` is Google-specific. In practice an opaque relay fronting a non-Gemini service at `https://relay.example/custom/v1beta` would still be silently rewritten to `/v1beta/models/{model}:generateContent`, breaking the deployment. Move `/v1beta`, `/v1beta/models`, and `/v1beta/openai` into the Google-host gated layer alongside `/v1`, `/models`, and friends. The unconditional layer now only accepts paths whose grammar is intrinsically Gemini — `/models/...:generateContent` method calls and the deep OpenAI-compat endpoints like `/openai/chat/completions` and `/openai/responses`. Pasted AI-Studio URLs such as `https://generativelanguage.googleapis.com/v1beta` still normalize because the host matches the whitelist. ## P2b — `model: "models/gemini-2.5-pro"` produced doubled path prefix Gemini SDKs (and the official `list_models` response) commonly surface model ids in resource-name form `models/gemini-2.5-pro`. Raw interpolation into `format!("/v1beta/models/{model}:...")` produced `/v1beta/models/models/gemini-2.5-pro:streamGenerateContent` which upstream rejects — yielding false-negative health checks for otherwise valid provider configs. Introduce `normalize_gemini_model_id(&str) -> &str` in `gemini_url` as the single source of truth: strips an optional leading `/` then an optional `models/` prefix, leaving bare ids untouched. Apply in the three call sites that build a Gemini method URL: - `services/stream_check.rs::resolve_claude_stream_url` (unified path) - `services/stream_check.rs::check_gemini_stream` (Gemini-only path) - `proxy/forwarder.rs::rewrite_claude_transform_endpoint` (production) Tests (9 new): - `gemini_url`: 3 regressions for opaque vs Google-host `/v1beta*` handling + 5 unit tests pinning `normalize_gemini_model_id` behavior (strip prefix, leave bare id, preserve nested slashes past the one stripped prefix, tolerate leading slash, pass through empty input). - `stream_check`: one end-to-end regression confirming `models/gemini-2.5-pro` collapses to the expected single-prefix URL. - `forwarder`: one end-to-end regression on the production rewrite path. All 864 lib tests pass; cargo fmt + clippy -D warnings clean. Addresses Codex P2 feedback on #1918. * fix(proxy/gemini): trim API key before provider-type detection and OAuth parsing Leading whitespace on a copied oauth_creds.json (e.g. trailing newline when the user copies the file content as-is) would slip past the `starts_with("ya29.") || starts_with('{')` prefix check in `ClaudeAdapter::provider_type`, causing the provider to be misclassified as raw-API-key Gemini and fall back to `x-goog-api-key` with the raw JSON as the key — which upstream rejects with 401. The frontend's `handleApiKeyChange` already trims on keystrokes but deep-link imports, the JSON editor, and live-config backfill all bypass that path. Trim at every backend extraction point so the coverage is uniform: - `ClaudeAdapter::extract_key` (5 env / fallback branches) gets `.map(str::trim)` before `.filter(|s| !s.is_empty())` so that whitespace-only values are also treated as missing. - `GeminiAdapter::extract_key_raw` gets the same chain (including the `.filter` it was missing before). - `GeminiAdapter::parse_oauth_credentials` gets a defensive `let key = key.trim();` at the entry as a belt-and-suspenders guard. Adds two regression tests covering JSON and bare `ya29.` keys with leading newline/space. * fix(proxy/gemini): gate generic REST suffix stripping behind Google host in non-full-URL mode `build_gemini_native_url` unconditionally stripped `/v1`, `/v1beta`, `/models`, and `/openai` suffixes from the base path regardless of host. This worked for Google's own endpoints but silently rewrote third-party relay URLs like `https://relay.example/custom/v1` to `.../custom/v1beta/models/...`, breaking any relay that mounts its Gemini-compatible namespace under a versioned prefix. The result was also asymmetric with the previously-fixed full-URL branch: toggling the "full URL" switch changed the outbound URL for the same base_url, which is exactly the kind of invisible behavior that makes debugging proxy deployments painful. Align `normalize_gemini_base_path` with `should_normalize_gemini_full_url`'s layered model: - Unconditional: `/models/...:method` structured paths and deep OpenAI-compat endpoints (`/openai/chat/completions`, `/openai/responses` and their versioned variants) — these are unambiguous Gemini-specific grammar on any host. - Google-host gated: generic `/v1`, `/v1beta`, `/models`, `/openai` suffixes only get stripped on `generativelanguage.googleapis.com`, `aiplatform.googleapis.com`, or `*-aiplatform.googleapis.com`. Other hosts preserve the prefix verbatim so relays keep their intended routing. Adds seven regression tests for the non-full-URL flow: opaque relay preservation (v1 / v1beta / models / openai suffix variants), Google host normalization (counter-case), and boundary cases (structured method path and deep OpenAI-compat endpoint stripped regardless of host). Test count: 864 -> 873. * Revert "fix(proxy/gemini): gate generic REST suffix stripping behind Google host in non-full-URL mode" This reverts commit d19ff09cb78088d0bf02dfc2b9d8e67e00bc9288. * test(proxy/gemini): pin non-full-URL versioned relay base stripping Adds two regression tests that lock in the intentional asymmetry between full-URL and non-full-URL modes: - Full-URL mode: opaque base path (e.g. `https://relay.example/custom/v1beta`) is preserved verbatim. Already covered by `preserves_opaque_full_url_with_bare_v1beta_suffix`. - Non-full-URL mode: base path MUST strip `/v1`, `/v1beta`, etc. so the standard `/v1beta/models/{model}:method` endpoint can be appended without producing a doubled `/v1beta/v1beta/models/...` path. The non-full-URL contract is "base URL + cc-switch appends the canonical Gemini endpoint". A user who needs a relay's custom namespace (e.g. `/v1/models/...`) must use full-URL mode and paste the complete method path. This commit adds regression coverage so a future attempt to mirror full-URL's host-whitelist gating into `normalize_gemini_base_path` will fail the test suite immediately. * chore(lint): address clippy 1.95 findings in existing modules CI upgraded to Rust 1.95 and flagged ten pre-existing warnings that older toolchains did not enforce. None relate to the Gemini proxy integration PR itself but they block CI on the feature branch, so clean them up here as a separate commit for easy review: collapsible_match: - proxy/providers/gemini_schema.rs: `"items" if value.is_object()` match guard instead of nested if. - proxy/providers/transform_responses.rs: fold `map_responses_stop_reason`'s `"completed"` / `"incomplete"` arms into match guards, relying on the existing `_ => "end_turn"` fall- through for non-matching guard conditions (semantics preserved). - services/session_usage_codex.rs: fold `"session_meta" if state.session_id.is_none()` guard, relying on the existing `_ => {}` fall-through. unnecessary_sort_by: - services/provider/endpoints.rs: `sort_by_key(|ep| Reverse(ep.added_at))`. - services/skill.rs (backup list): same Reverse idiom on `created_at`. - services/skill.rs (skill listings x2): `sort_by_key(|s| s.name.to_lowercase())`. useless_conversion: - services/skill.rs: drop the explicit `.into_iter()` on `zip`'s argument. while_let_loop: - services/webdav_auto_sync.rs: `while let Some(wait_for) = ...` instead of `loop { let Some(...) = ... else { break }; ... }`. All changes are mechanical and preserve behavior. `cargo test --lib` remains green (868 passed). * fix(proxy/gemini): reconcile synthesized tool-call ids with later real ids + preserve thoughtSignature Three related findings on `streaming_gemini.rs` for Gemini's cumulative `streamGenerateContent` stream, all centered on `merge_tool_call_snapshots`: 1. (P1) Match upgraded tool-call IDs by position. When Gemini delivers a `functionCall` without an id on chunk 1 (cc-switch synthesizes `gemini_synth_*`) and then upgrades it to a real id on chunk 2, the `Some(incoming_id)` branch only matched by id and missed the existing synthesized snapshot. A second entry would be pushed, yielding duplicate `tool_use` content blocks at stream end — one with the synthesized id, one with the real id — which could trigger duplicate tool execution and break tool_result correlation. Add a positional fallback: when no id match exists but the same-position slot holds a synthesized id, merge into it. `or(preserved_id)` already lets the real id win the merge. 2. (P2) Preserve prior thoughtSignature when merging snapshots. `tool_call_snapshots[index] = tool_call` overwrote the slot entirely, dropping any `thoughtSignature` captured on an earlier chunk if the current cumulative snapshot omitted it. Since `build_shadow_assistant_parts` writes `thoughtSignature` into the shadow turn from `tool_call.thought_signature`, a dropped signature would cause later replay requests to Gemini to be rejected with invalid-signature errors. Preserve the existing signature when the incoming chunk does not carry one. 3. (P2) Document the part-order streaming trade-off. All `tool_use` content blocks are emitted after the final text `content_block_stop`, so interleaved [text, functionCall, text, functionCall] parts arrive at the Anthropic client as [text(concat), tool_use, tool_use] — different from the non-streaming transformer, which preserves part order. This is intentional given the cumulative snapshot model and the consumers we target (claude-code-like clients don't depend on strict interleaving for tool execution correctness). Add a block comment at the flush site describing the trade-off and what a strict-order fix would entail, so this isn't rediscovered as a bug later. Regression tests: - upgraded_real_id_merges_into_existing_synthesized_snapshot - thought_signature_preserved_when_later_chunk_omits_it Test count: 868 -> 870. clippy 1.95 clean. fmt clean. * fix(proxy/gemini): prefer exact tool-call id over normalized-name fallback The shadow-turn matcher used a three-branch `||` chain (id / full name / normalized name). When two tools share a suffix (e.g. `server_a:search` and `server_b:search`), the normalized-name clause could short-circuit on an earlier turn whose id is actually wrong for the incoming tool_use, mis-routing replay state (functionCall id / thoughtSignature) for later tool_result resolution. Split matching into two layers: when the incoming message carries any tool_use ids, run id-based lookup first and return on the earliest hit. Only fall back to full-name / normalized-name matching when the incoming ids are absent or none of them resolve. Add two regressions: - shadow_replay_prefers_exact_id_match_over_normalized_name_collision Two shadow turns with colliding normalized names and two assistant messages whose ids cross the positional order; asserts each message replays the id-correct shadow turn (including thoughtSignature). - shadow_replay_falls_back_to_name_when_ids_absent Shadow turn with no id and incoming tool_use with an empty id; asserts the name fallback still populates the replayed part. --------- Co-authored-by: Jason --- src-tauri/src/proxy/forwarder.rs | 183 +- src-tauri/src/proxy/gemini_url.rs | 665 +++++ src-tauri/src/proxy/handler_context.rs | 2 + src-tauri/src/proxy/handlers.rs | 23 +- src-tauri/src/proxy/mod.rs | 1 + src-tauri/src/proxy/providers/claude.rs | 376 ++- src-tauri/src/proxy/providers/gemini.rs | 14 +- .../src/proxy/providers/gemini_schema.rs | 338 +++ .../src/proxy/providers/gemini_shadow.rs | 389 +++ src-tauri/src/proxy/providers/mod.rs | 12 + src-tauri/src/proxy/providers/streaming.rs | 7 +- .../src/proxy/providers/streaming_gemini.rs | 1054 ++++++++ .../proxy/providers/streaming_responses.rs | 7 +- .../src/proxy/providers/transform_gemini.rs | 2152 +++++++++++++++++ .../proxy/providers/transform_responses.rs | 19 +- src-tauri/src/proxy/response_handler.rs | 7 +- src-tauri/src/proxy/response_processor.rs | 9 +- src-tauri/src/proxy/server.rs | 6 +- src-tauri/src/proxy/session.rs | 69 + src-tauri/src/proxy/sse.rs | 42 +- src-tauri/src/proxy/thinking_rectifier.rs | 27 + src-tauri/src/services/provider/endpoints.rs | 2 +- src-tauri/src/services/session_usage_codex.rs | 22 +- src-tauri/src/services/skill.rs | 8 +- src-tauri/src/services/stream_check.rs | 125 +- src-tauri/src/services/webdav_auto_sync.rs | 5 +- .../providers/forms/ClaudeFormFields.tsx | 16 +- .../providers/forms/shared/EndpointField.tsx | 5 +- src/config/claudeProviderPresets.ts | 28 +- src/i18n/locales/en.json | 3 + src/i18n/locales/ja.json | 3 + src/i18n/locales/zh.json | 3 + src/types.ts | 14 +- 33 files changed, 5542 insertions(+), 94 deletions(-) create mode 100644 src-tauri/src/proxy/gemini_url.rs create mode 100644 src-tauri/src/proxy/providers/gemini_schema.rs create mode 100644 src-tauri/src/proxy/providers/gemini_shadow.rs create mode 100644 src-tauri/src/proxy/providers/streaming_gemini.rs create mode 100644 src-tauri/src/proxy/providers/transform_gemini.rs diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index cf202118d..7c9fbc124 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -9,7 +9,10 @@ use super::{ failover_switch::FailoverSwitchManager, log_codes::fwd as log_fwd, provider_router::ProviderRouter, - providers::{get_adapter, AuthInfo, AuthStrategy, ProviderAdapter, ProviderType}, + providers::{ + gemini_shadow::GeminiShadowStore, get_adapter, AuthInfo, AuthStrategy, ProviderAdapter, + ProviderType, + }, thinking_budget_rectifier::{rectify_thinking_budget, should_rectify_thinking_budget}, thinking_rectifier::{ normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature, @@ -43,12 +46,15 @@ pub struct RequestForwarder { router: Arc, status: Arc>, current_providers: Arc>>, + gemini_shadow: Arc, /// 故障转移切换管理器 failover_manager: Arc, /// AppHandle,用于发射事件和更新托盘 app_handle: Option, /// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘) current_provider_id_at_start: String, + /// 代理会话 ID(用于 Gemini Native shadow replay) + session_id: String, /// 整流器配置 rectifier_config: RectifierConfig, /// 优化器配置 @@ -66,9 +72,11 @@ impl RequestForwarder { non_streaming_timeout: u64, status: Arc>, current_providers: Arc>>, + gemini_shadow: Arc, failover_manager: Arc, app_handle: Option, current_provider_id_at_start: String, + session_id: String, _streaming_first_byte_timeout: u64, _streaming_idle_timeout: u64, rectifier_config: RectifierConfig, @@ -79,9 +87,11 @@ impl RequestForwarder { router, status, current_providers, + gemini_shadow, failover_manager, app_handle, current_provider_id_at_start, + session_id, rectifier_config, optimizer_config, copilot_optimizer_config, @@ -918,7 +928,7 @@ impl RequestForwarder { let api_format = resolved_claude_api_format .as_deref() .unwrap_or_else(|| super::providers::get_claude_api_format(provider)); - rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot) + rewrite_claude_transform_endpoint(endpoint, api_format, is_copilot, &mapped_body) } else { ( endpoint.to_string(), @@ -928,7 +938,13 @@ impl RequestForwarder { ) }; - let url = if is_full_url { + let url = if matches!(resolved_claude_api_format.as_deref(), Some("gemini_native")) { + super::gemini_url::resolve_gemini_native_url( + &base_url, + &effective_endpoint, + is_full_url, + ) + } else if is_full_url { append_query_to_full_url(&base_url, passthrough_query.as_deref()) } else { adapter.build_url(&base_url, &effective_endpoint) @@ -944,6 +960,8 @@ impl RequestForwarder { mapped_body, provider, api_format, + Some(&self.session_id), + Some(self.gemini_shadow.as_ref()), )? } else { adapter.transform_request(mapped_body, provider)? @@ -1143,8 +1161,11 @@ impl RequestForwarder { .ok() .and_then(|u| u.authority().map(|a| a.to_string())); + let should_send_anthropic_headers = adapter.name() == "Claude" + && matches!(resolved_claude_api_format.as_deref(), Some("anthropic")); + // 预计算 anthropic-beta 值(仅 Claude) - let anthropic_beta_value = if adapter.name() == "Claude" { + let anthropic_beta_value = if should_send_anthropic_headers { const CLAUDE_CODE_BETA: &str = "claude-code-20250219"; Some(if let Some(beta) = headers.get("anthropic-beta") { if let Ok(beta_str) = beta.to_str() { @@ -1264,8 +1285,10 @@ impl RequestForwarder { // --- anthropic-version — 透传客户端值 --- if key_str.eq_ignore_ascii_case("anthropic-version") { - saw_anthropic_version = true; - ordered_headers.append(key.clone(), value.clone()); + if should_send_anthropic_headers { + saw_anthropic_version = true; + ordered_headers.append(key.clone(), value.clone()); + } continue; } @@ -1306,7 +1329,7 @@ impl RequestForwarder { } // anthropic-version:仅在缺失时补充默认值 - if adapter.name() == "Claude" && !saw_anthropic_version { + if should_send_anthropic_headers && !saw_anthropic_version { ordered_headers.append( "anthropic-version", http::HeaderValue::from_static("2023-06-01"), @@ -1650,6 +1673,7 @@ fn rewrite_claude_transform_endpoint( endpoint: &str, api_format: &str, is_copilot: bool, + body: &Value, ) -> (String, Option) { let (path, query) = split_endpoint_and_query(endpoint); let passthrough_query = if is_claude_messages_path(path) { @@ -1662,6 +1686,36 @@ fn rewrite_claude_transform_endpoint( return (endpoint.to_string(), passthrough_query); } + if api_format == "gemini_native" { + let model = + super::providers::transform_gemini::extract_gemini_model(body).unwrap_or("unknown"); + // Accept both bare ids (`gemini-2.5-pro`) and the resource-name + // form (`models/gemini-2.5-pro`) that Gemini SDKs emit. See + // `normalize_gemini_model_id` for rationale. + let model = super::gemini_url::normalize_gemini_model_id(model); + let is_stream = body + .get("stream") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let target_path = if is_stream { + format!("/v1beta/models/{model}:streamGenerateContent") + } else { + format!("/v1beta/models/{model}:generateContent") + }; + + let rewritten_query = merge_query_params( + passthrough_query.as_deref(), + if is_stream { Some("alt=sse") } else { None }, + ); + + let rewritten = match rewritten_query.as_deref() { + Some(query) if !query.is_empty() => format!("{target_path}?{query}"), + _ => target_path, + }; + + return (rewritten, rewritten_query); + } + let target_path = if is_copilot && api_format == "openai_responses" { "/v1/responses" } else if is_copilot { @@ -1680,6 +1734,26 @@ fn rewrite_claude_transform_endpoint( (rewritten, passthrough_query) } +fn merge_query_params(base_query: Option<&str>, extra_param: Option<&str>) -> Option { + let mut params: Vec = base_query + .into_iter() + .flat_map(|query| query.split('&')) + .filter(|pair| !pair.is_empty()) + .filter(|pair| !pair.starts_with("alt=")) + .map(ToString::to_string) + .collect(); + + if let Some(extra_param) = extra_param { + params.push(extra_param.to_string()); + } + + if params.is_empty() { + None + } else { + Some(params.join("&")) + } +} + fn append_query_to_full_url(base_url: &str, query: Option<&str>) -> String { match query { Some(query) if !query.is_empty() => { @@ -1808,6 +1882,7 @@ mod tests { "/v1/messages?beta=true&foo=bar", "openai_chat", false, + &json!({ "model": "gpt-5.4" }), ); assert_eq!(endpoint, "/v1/chat/completions?foo=bar"); @@ -1820,6 +1895,7 @@ mod tests { "/claude/v1/messages?beta=true&x-id=1", "openai_responses", false, + &json!({ "model": "gpt-5.4" }), ); assert_eq!(endpoint, "/v1/responses?x-id=1"); @@ -1828,8 +1904,12 @@ mod tests { #[test] fn rewrite_claude_transform_endpoint_uses_copilot_path() { - let (endpoint, passthrough_query) = - rewrite_claude_transform_endpoint("/v1/messages?beta=true&x-id=1", "anthropic", true); + let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( + "/v1/messages?beta=true&x-id=1", + "anthropic", + true, + &json!({ "model": "claude-sonnet-4-6" }), + ); assert_eq!(endpoint, "/chat/completions?x-id=1"); assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); @@ -1841,12 +1921,60 @@ mod tests { "/v1/messages?beta=true&x-id=1", "openai_responses", true, + &json!({ "model": "gpt-5.4" }), ); assert_eq!(endpoint, "/v1/responses?x-id=1"); assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); } + #[test] + fn rewrite_claude_transform_endpoint_maps_gemini_generate_content() { + let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( + "/v1/messages?beta=true&x-id=1", + "gemini_native", + false, + &json!({ "model": "gemini-2.5-pro" }), + ); + + assert_eq!( + endpoint, + "/v1beta/models/gemini-2.5-pro:generateContent?x-id=1" + ); + assert_eq!(passthrough_query.as_deref(), Some("x-id=1")); + } + + /// Regression: body.model arriving as the resource-name form + /// `models/gemini-2.5-pro` must not produce a doubled + /// `/v1beta/models/models/...` path. + #[test] + fn rewrite_claude_transform_endpoint_strips_gemini_model_resource_prefix() { + let (endpoint, _) = rewrite_claude_transform_endpoint( + "/v1/messages", + "gemini_native", + false, + &json!({ "model": "models/gemini-2.5-pro" }), + ); + + assert_eq!(endpoint, "/v1beta/models/gemini-2.5-pro:generateContent"); + } + + #[test] + fn rewrite_claude_transform_endpoint_maps_gemini_streaming() { + let (endpoint, passthrough_query) = rewrite_claude_transform_endpoint( + "/v1/messages?beta=true", + "gemini_native", + false, + &json!({ "model": "gemini-2.5-flash", "stream": true }), + ); + + assert_eq!( + endpoint, + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + assert_eq!(passthrough_query.as_deref(), Some("alt=sse")); + } + #[test] fn append_query_to_full_url_preserves_existing_query_string() { let url = append_query_to_full_url("https://relay.example/api?foo=bar", Some("x-id=1")); @@ -1854,6 +1982,43 @@ mod tests { assert_eq!(url, "https://relay.example/api?foo=bar&x-id=1"); } + #[test] + fn build_gemini_native_url_uses_origin_when_base_ends_with_v1beta() { + let url = crate::proxy::gemini_url::build_gemini_native_url( + "https://generativelanguage.googleapis.com/v1beta", + "/v1beta/models/gemini-2.5-pro:generateContent", + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent" + ); + } + + #[test] + fn build_gemini_native_url_uses_origin_when_base_already_contains_models_prefix() { + let url = crate::proxy::gemini_url::build_gemini_native_url( + "https://generativelanguage.googleapis.com/v1beta/models", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + #[test] + fn resolve_gemini_native_url_keeps_opaque_full_url_as_is() { + let url = crate::proxy::gemini_url::resolve_gemini_native_url( + "https://relay.example/custom/generate-content", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse"); + } + #[test] fn force_identity_for_stream_flag_requests() { let headers = HeaderMap::new(); diff --git a/src-tauri/src/proxy/gemini_url.rs b/src-tauri/src/proxy/gemini_url.rs new file mode 100644 index 000000000..c75136677 --- /dev/null +++ b/src-tauri/src/proxy/gemini_url.rs @@ -0,0 +1,665 @@ +//! Gemini Native URL helpers. +//! +//! Normalizes legacy Gemini/OpenAI-compatible base URLs into the canonical +//! Gemini Native `models/*:generateContent` endpoints. + +/// Normalize a Gemini model identifier to its bare form, stripping an +/// optional leading `models/` (and any leading `/`) so that the value can +/// be safely interpolated into a URL template like +/// `/v1beta/models/{model}:generateContent`. +/// +/// Gemini SDKs and documentation commonly surface model ids as +/// `models/gemini-2.5-pro` (the resource-name form). Passing that value +/// through to the format string would otherwise yield a doubled prefix +/// like `/v1beta/models/models/gemini-2.5-pro:generateContent`, which is +/// rejected by the upstream API and turns any health check or live +/// request into a false negative. +pub fn normalize_gemini_model_id(model: &str) -> &str { + let trimmed = model.strip_prefix('/').unwrap_or(model); + trimmed.strip_prefix("models/").unwrap_or(trimmed) +} + +pub fn resolve_gemini_native_url(base_url: &str, endpoint: &str, is_full_url: bool) -> String { + if !is_full_url || should_normalize_gemini_full_url(base_url) { + return build_gemini_native_url(base_url, endpoint); + } + + let base_url = base_url + .split_once('#') + .map_or(base_url, |(base, _)| base) + .trim_end_matches('/'); + let (base_without_query, base_query) = split_query(base_url); + let (_, endpoint_query) = split_query(endpoint); + + let mut url = base_without_query.to_string(); + if let Some(query) = merge_queries(base_query, endpoint_query) { + url.push('?'); + url.push_str(&query); + } + + url +} + +pub fn build_gemini_native_url(base_url: &str, endpoint: &str) -> String { + let base_url = base_url + .split_once('#') + .map_or(base_url, |(base, _)| base) + .trim_end_matches('/'); + let (base_without_query, base_query) = split_query(base_url); + let (endpoint_without_query, endpoint_query) = split_query(endpoint); + + let endpoint_path = format!("/{}", endpoint_without_query.trim_start_matches('/')); + let (origin, raw_path) = split_origin_and_path(base_without_query); + let prefix_path = normalize_gemini_base_path(raw_path); + + let mut url = if prefix_path.is_empty() { + format!("{origin}{endpoint_path}") + } else { + format!("{origin}{prefix_path}{endpoint_path}") + }; + + if let Some(query) = merge_queries(base_query, endpoint_query) { + url.push('?'); + url.push_str(&query); + } + + url +} + +fn should_normalize_gemini_full_url(base_url: &str) -> bool { + let base_url = base_url + .split_once('#') + .map_or(base_url, |(base, _)| base) + .trim_end_matches('/'); + let (base_without_query, _) = split_query(base_url); + let (origin, path) = split_origin_and_path(base_without_query); + + if path.is_empty() || path == "/" { + return true; + } + + let path = path.trim_end_matches('/'); + let on_google_host = is_google_gemini_host(extract_host(origin)); + + // Unconditional layer: only paths whose grammar is *intrinsically* + // Gemini-specific — the `/models/...:generateContent` method-call + // shape and the deep OpenAI-compat endpoints (`/openai/chat/completions`, + // `/openai/responses`) that are implausibly used as a relay's fixed + // terminal path — get rewritten regardless of host. + if matches_structured_gemini_models_path(path) + || path.ends_with("/v1beta/openai/chat/completions") + || path.ends_with("/v1beta/openai/responses") + || path.ends_with("/openai/chat/completions") + || path.ends_with("/openai/responses") + || path.ends_with("/v1/openai/chat/completions") + || path.ends_with("/v1/openai/responses") + { + return true; + } + + // All other version / resource-root suffixes — `/v1beta`, `/v1`, + // `/models`, `/openai`, and variants — could legitimately be an + // opaque relay's fixed endpoint (`https://relay.example/custom/v1beta` + // is a real deployment shape, even if uncommon outside Google's + // ecosystem). Only rewrite when the host itself is Google's Gemini + // or Vertex AI endpoint. + if on_google_host + && (path.ends_with("/v1beta") + || path.ends_with("/v1beta/models") + || path.ends_with("/v1beta/openai") + || path.ends_with("/v1") + || path.ends_with("/v1/models") + || path.ends_with("/models") + || path.ends_with("/v1/openai") + || path.ends_with("/openai")) + { + return true; + } + + false +} + +/// Extract the host portion of an origin like `https://host:port` or +/// `https://host`. Returns an empty string if no host can be found (e.g. +/// bare `http://`). +fn extract_host(origin: &str) -> &str { + let after_scheme = origin.split_once("://").map_or(origin, |(_, rest)| rest); + // authority may carry credentials (`user:pass@host`) and a port + // (`host:port`). Strip userinfo first, then port. + let without_userinfo = after_scheme + .rsplit_once('@') + .map_or(after_scheme, |(_, h)| h); + let without_port = without_userinfo + .split_once(':') + .map_or(without_userinfo, |(h, _)| h); + // Strip trailing `/` defensively (split_origin_and_path already handled + // it, but this helper may be reused elsewhere). + without_port.trim_end_matches('/') +} + +/// Returns true when `host` is one of Google's Gemini / Vertex AI endpoints. +/// Case-insensitive. Requires exact match or a real `-aiplatform.googleapis.com` +/// subdomain suffix — not a substring match, so lookalikes like +/// `aiplatform.example.com` are rejected. +fn is_google_gemini_host(host: &str) -> bool { + if host.is_empty() { + return false; + } + let host_lower = host.to_ascii_lowercase(); + host_lower == "generativelanguage.googleapis.com" + || host_lower == "aiplatform.googleapis.com" + || host_lower.ends_with("-aiplatform.googleapis.com") +} + +fn split_query(input: &str) -> (&str, Option<&str>) { + input + .split_once('?') + .map_or((input, None), |(path, query)| (path, Some(query))) +} + +fn split_origin_and_path(base_url: &str) -> (&str, &str) { + let Some(scheme_sep) = base_url.find("://") else { + return (base_url, ""); + }; + let authority_start = scheme_sep + 3; + let Some(path_start_rel) = base_url[authority_start..].find('/') else { + return (base_url, ""); + }; + let path_start = authority_start + path_start_rel; + (&base_url[..path_start], &base_url[path_start..]) +} + +fn normalize_gemini_base_path(path: &str) -> String { + let path = path.trim_end_matches('/'); + if path.is_empty() || path == "/" { + return String::new(); + } + + for marker in ["/v1beta/models/", "/v1/models/", "/models/"] { + if let Some(index) = path.find(marker) { + return normalize_prefix(&path[..index]); + } + } + + for suffix in [ + "/v1beta/openai/chat/completions", + "/v1/openai/chat/completions", + "/openai/chat/completions", + "/v1beta/openai/responses", + "/v1/openai/responses", + "/openai/responses", + "/v1beta/openai", + "/v1/openai", + "/openai", + "/v1beta/models", + "/v1/models", + "/models", + "/v1beta", + "/v1", + ] { + if path == suffix { + return String::new(); + } + if let Some(prefix) = path.strip_suffix(suffix) { + return normalize_prefix(prefix); + } + } + + path.to_string() +} + +fn normalize_prefix(prefix: &str) -> String { + let prefix = prefix.trim_end_matches('/'); + if prefix.is_empty() || prefix == "/" { + String::new() + } else { + prefix.to_string() + } +} + +/// Returns true when `path` contains a `/models/` segment followed by a +/// canonical Gemini method call (`*:generateContent` or +/// `*:streamGenerateContent`). The `/models/` segment alone is not enough: +/// opaque relay routes such as `/v1/models/invoke` or `/custom/models/foo` +/// also contain `/models/` but are not Gemini-structured and must not be +/// rewritten. +fn matches_structured_gemini_models_path(path: &str) -> bool { + let mut cursor = path; + while let Some(idx) = cursor.find("/models/") { + let after = &cursor[idx + "/models/".len()..]; + if after.contains(":generateContent") || after.contains(":streamGenerateContent") { + return true; + } + // Advance past this `/models/` occurrence so a later Gemini-style + // segment in the same path (unusual but cheap to handle) can still + // match. + cursor = &cursor[idx + "/models/".len()..]; + } + false +} + +fn merge_queries(base_query: Option<&str>, endpoint_query: Option<&str>) -> Option { + let parts: Vec<&str> = [base_query, endpoint_query] + .into_iter() + .flatten() + .flat_map(|query| query.split('&')) + .filter(|part| !part.is_empty()) + .collect(); + + if parts.is_empty() { + None + } else { + Some(parts.join("&")) + } +} + +#[cfg(test)] +mod tests { + use super::{build_gemini_native_url, normalize_gemini_model_id, resolve_gemini_native_url}; + + #[test] + fn strips_version_root_for_official_base() { + let url = build_gemini_native_url( + "https://generativelanguage.googleapis.com/v1beta", + "/v1beta/models/gemini-2.5-pro:generateContent", + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent" + ); + } + + #[test] + fn strips_openai_compat_path_for_official_base() { + let url = build_gemini_native_url( + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", + "/v1beta/models/gemini-2.5-pro:generateContent", + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent" + ); + } + + #[test] + fn preserves_custom_proxy_prefix_while_stripping_openai_suffix() { + let url = build_gemini_native_url( + "https://proxy.example.com/google/v1beta/openai/chat/completions", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + ); + + assert_eq!( + url, + "https://proxy.example.com/google/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + #[test] + fn strips_model_method_path_from_full_url_base() { + let url = build_gemini_native_url( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + #[test] + fn resolves_structured_full_url_by_normalizing_to_requested_method() { + let url = resolve_gemini_native_url( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + #[test] + fn resolves_opaque_full_url_without_appending_gemini_models_path() { + let url = resolve_gemini_native_url( + "https://relay.example/custom/generate-content", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse"); + } + + #[test] + fn preserves_opaque_full_url_containing_models_segment() { + let url = resolve_gemini_native_url( + "https://relay.example/custom/models/invoke", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!(url, "https://relay.example/custom/models/invoke?alt=sse"); + } + + /// Regression: a relay whose fixed path starts with `/v1/models/` is an + /// opaque route, not a Gemini-structured endpoint. The previous + /// heuristic matched any `contains("/v1/models/")` and rewrote it to + /// `/v1beta/models/{model}:generateContent`, dropping the relay's own + /// route component (`/invoke`) and sending traffic to the wrong place. + #[test] + fn preserves_opaque_full_url_with_v1_models_prefix() { + let url = resolve_gemini_native_url( + "https://relay.example/v1/models/invoke", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!(url, "https://relay.example/v1/models/invoke?alt=sse"); + } + + /// Same regression, `/v1beta/models/` variant — relays may use Google's + /// path layout defensively for routing while still being opaque. + #[test] + fn preserves_opaque_full_url_with_v1beta_models_prefix() { + let url = resolve_gemini_native_url( + "https://relay.example/v1beta/models/route", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!(url, "https://relay.example/v1beta/models/route?alt=sse"); + } + + /// Counter-case: a full URL that *does* carry a genuine Gemini method + /// segment (`:generateContent`) under `/v1/models/` should still be + /// recognized as structured and normalized to the requested model. + #[test] + fn normalizes_structured_v1_models_path_with_method_segment() { + let url = resolve_gemini_native_url( + "https://relay.example/v1/models/gemini-2.5-pro:generateContent", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!( + url, + "https://relay.example/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + // ------------------------------------------------------------------ + // Google-host whitelist tests (generic REST suffix handling) + // + // Generic REST conventions like `/v1`, `/models`, `/openai` legitimately + // appear on opaque relays. `should_normalize_gemini_full_url` only + // treats these as structured Gemini endpoints when the host itself is + // Google's Gemini or Vertex AI endpoint. + // ------------------------------------------------------------------ + + /// Regression: a relay whose fixed path ends with `/v1` (a ubiquitous + /// REST convention) used to be rewritten to + /// `/v1beta/models/{model}:generateContent`, dropping the relay's own + /// `/v1` endpoint. + #[test] + fn preserves_opaque_full_url_with_v1_suffix() { + let url = resolve_gemini_native_url( + "https://relay.example/custom/v1", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!(url, "https://relay.example/custom/v1?alt=sse"); + } + + /// Companion case: bare `/models` suffix on a non-Google host is a + /// generic REST path, not a Gemini-structured endpoint. + #[test] + fn preserves_opaque_full_url_with_models_suffix() { + let url = resolve_gemini_native_url( + "https://relay.example/custom/models", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!(url, "https://relay.example/custom/models?alt=sse"); + } + + /// Companion case: `/v1/models` — same ambiguity as `/models`, with the + /// version prefix. Must stay as-is on non-Google hosts. + #[test] + fn preserves_opaque_full_url_with_v1_models_suffix() { + let url = resolve_gemini_native_url( + "https://relay.example/custom/v1/models", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!(url, "https://relay.example/custom/v1/models?alt=sse"); + } + + /// Companion case: a relay that exposes an `/openai` compatibility + /// surface without the deep `/openai/chat/completions` path. Must stay + /// as-is on non-Google hosts. + #[test] + fn preserves_opaque_full_url_with_openai_suffix_on_non_google_host() { + let url = resolve_gemini_native_url( + "https://relay.example/custom/openai", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!(url, "https://relay.example/custom/openai?alt=sse"); + } + + /// Counter-case: `/v1` on the official Gemini host must still be + /// normalized to the full `/v1beta/models/...` endpoint — users who + /// paste `https://generativelanguage.googleapis.com/v1` as their base + /// URL expect the proxy to resolve the method path. + #[test] + fn normalizes_google_host_with_v1_suffix() { + let url = resolve_gemini_native_url( + "https://generativelanguage.googleapis.com/v1", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + /// Counter-case: `/models` on the official Gemini host is recognized + /// and normalized. + #[test] + fn normalizes_google_host_with_models_suffix() { + let url = resolve_gemini_native_url( + "https://generativelanguage.googleapis.com/models", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + /// Counter-case: Vertex AI regional endpoints live under + /// `*-aiplatform.googleapis.com`. Those should also be treated as + /// Google-host for the whitelist. + #[test] + fn normalizes_vertex_aiplatform_host_with_v1_suffix() { + let url = resolve_gemini_native_url( + "https://us-central1-aiplatform.googleapis.com/v1", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!( + url, + "https://us-central1-aiplatform.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + /// Safety: the Google-host whitelist must do an exact/suffix match, not + /// a `contains`. A lookalike host like `aiplatform.example.com` must + /// NOT be treated as Google. + #[test] + fn preserves_non_google_aiplatform_lookalike_host() { + let url = resolve_gemini_native_url( + "https://aiplatform.example.com/v1", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!(url, "https://aiplatform.example.com/v1?alt=sse"); + } + + /// Regression: `/v1beta` by itself is Google-conventional but not + /// literally impossible on other hosts. An opaque relay fronting a + /// non-Gemini service at `https://relay.example/custom/v1beta` would + /// be silently rewritten if `/v1beta` were classified as unconditional + /// structured Gemini. Require the Google-host whitelist instead. + #[test] + fn preserves_opaque_full_url_with_bare_v1beta_suffix() { + let url = resolve_gemini_native_url( + "https://relay.example/custom/v1beta", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!(url, "https://relay.example/custom/v1beta?alt=sse"); + } + + /// Companion case: `/v1beta/models` (no method segment) on a non-Google + /// host stays as-is too. + #[test] + fn preserves_opaque_full_url_with_v1beta_models_suffix_no_method() { + let url = resolve_gemini_native_url( + "https://relay.example/custom/v1beta/models", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!(url, "https://relay.example/custom/v1beta/models?alt=sse"); + } + + /// Counter-case: `/v1beta` on the official Gemini host must still + /// normalize — this is the canonical base URL shape users paste from + /// AI Studio documentation. + #[test] + fn normalizes_google_host_with_v1beta_suffix() { + let url = resolve_gemini_native_url( + "https://generativelanguage.googleapis.com/v1beta", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + true, + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + /// Regression guard: in non-full-URL mode, a versioned third-party + /// relay base must have its `/v1beta` suffix **stripped** so the + /// appended standard endpoint (`/v1beta/models/{model}:method`) does + /// not produce a doubled `/v1beta/v1beta/models/...` path. Non-full + /// mode's contract is "base URL + cc-switch appends the canonical + /// Gemini endpoint" — a user who wants a relay's custom namespace + /// (e.g. `/v1/models/...`) must use full-URL mode instead. + /// + /// This test pins the intentional asymmetry with + /// `preserves_opaque_full_url_with_bare_v1beta_suffix` (full-URL + /// preserves, non-full strips) so nobody "fixes" one side into + /// breaking the other. + #[test] + fn strips_versioned_relay_base_suffix_in_non_full_url_mode() { + let url = resolve_gemini_native_url( + "https://relay.example/custom/v1beta", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + false, + ); + + assert_eq!( + url, + "https://relay.example/custom/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + /// Companion case: `/v1` base suffix also stripped in non-full-URL + /// mode regardless of host. + #[test] + fn strips_v1_relay_base_suffix_in_non_full_url_mode() { + let url = resolve_gemini_native_url( + "https://relay.example/custom/v1", + "/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse", + false, + ); + + assert_eq!( + url, + "https://relay.example/custom/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + // ------------------------------------------------------------------ + // Model ID normalization tests. + // + // Gemini SDKs and documentation commonly surface model identifiers as + // `models/gemini-2.5-pro` (resource-name form). If that value flows + // straight into our URL builder, the format string + // `/v1beta/models/{model}:generateContent` produces a doubled prefix + // `/v1beta/models/models/gemini-2.5-pro:generateContent`, which is + // rejected upstream. `normalize_gemini_model_id` is the single source + // of truth callers should run the model through first. + // ------------------------------------------------------------------ + + #[test] + fn normalize_model_id_strips_models_prefix() { + assert_eq!( + normalize_gemini_model_id("models/gemini-2.5-pro"), + "gemini-2.5-pro" + ); + } + + #[test] + fn normalize_model_id_leaves_bare_id_unchanged() { + assert_eq!( + normalize_gemini_model_id("gemini-2.5-pro"), + "gemini-2.5-pro" + ); + } + + #[test] + fn normalize_model_id_preserves_nested_slashes_after_prefix() { + // e.g. tuned model resource like `models/gemini-2.5-pro/tunedModels/xxx` + // — the caller asked for a specific tuned model resource, keep its + // identity intact after stripping only the single leading prefix. + assert_eq!( + normalize_gemini_model_id("models/tunedModels/my-tuned"), + "tunedModels/my-tuned" + ); + } + + #[test] + fn normalize_model_id_tolerates_leading_slash() { + assert_eq!( + normalize_gemini_model_id("/models/gemini-2.5-flash"), + "gemini-2.5-flash" + ); + } + + #[test] + fn normalize_model_id_preserves_empty_input() { + // Edge: caller has no model at all. Pass through so the URL error + // surfaces at the request layer rather than producing a misleading + // empty segment here. + assert_eq!(normalize_gemini_model_id(""), ""); + } +} diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index bad855a91..da2413ec4 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -218,9 +218,11 @@ impl RequestContext { non_streaming_timeout, state.status.clone(), state.current_providers.clone(), + state.gemini_shadow.clone(), state.failover_manager.clone(), state.app_handle.clone(), self.current_provider_id.clone(), + self.session_id.clone(), first_byte_timeout, idle_timeout, self.rectifier_config.clone(), diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index c203cb8bb..086f944e6 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -15,8 +15,9 @@ use super::{ handler_context::RequestContext, providers::{ get_adapter, get_claude_api_format, streaming::create_anthropic_sse_stream, + streaming_gemini::create_anthropic_sse_stream_from_gemini, streaming_responses::create_anthropic_sse_stream_from_responses, transform, - transform_responses, + transform_gemini, transform_responses, }, response_processor::{ create_logged_passthrough_stream, process_response, read_decoded_body, @@ -145,11 +146,13 @@ async fn handle_claude_transform( response: super::hyper_client::ProxyResponse, ctx: &RequestContext, state: &ProxyState, - _original_body: &Value, + original_body: &Value, is_stream: bool, api_format: &str, ) -> Result { let status = response.status(); + let tool_schema_hints = transform_gemini::extract_anthropic_tool_schema_hints(original_body); + let tool_schema_hints = (!tool_schema_hints.is_empty()).then_some(tool_schema_hints); if is_stream { // 根据 api_format 选择流式转换器 @@ -158,6 +161,14 @@ async fn handle_claude_transform( dyn futures::Stream> + Send + Unpin, > = if api_format == "openai_responses" { Box::new(Box::pin(create_anthropic_sse_stream_from_responses(stream))) + } else if api_format == "gemini_native" { + Box::new(Box::pin(create_anthropic_sse_stream_from_gemini( + stream, + Some(state.gemini_shadow.clone()), + Some(ctx.provider.id.clone()), + Some(ctx.session_id.clone()), + tool_schema_hints.clone(), + ))) } else { Box::new(Box::pin(create_anthropic_sse_stream(stream))) }; @@ -242,6 +253,14 @@ async fn handle_claude_transform( // 根据 api_format 选择非流式转换器 let anthropic_response = if api_format == "openai_responses" { transform_responses::responses_to_anthropic(upstream_response) + } else if api_format == "gemini_native" { + transform_gemini::gemini_to_anthropic_with_shadow_and_hints( + upstream_response, + Some(state.gemini_shadow.as_ref()), + Some(&ctx.provider.id), + Some(&ctx.session_id), + tool_schema_hints.as_ref(), + ) } else { transform::openai_to_anthropic(upstream_response) } diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index a14818c5d..48cec6b38 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -10,6 +10,7 @@ pub mod error; pub mod error_mapper; pub(crate) mod failover_switch; mod forwarder; +pub mod gemini_url; pub mod handler_config; pub mod handler_context; mod handlers; diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index f5825bcad..604c9ebfe 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -6,6 +6,7 @@ //! - **anthropic** (默认): Anthropic Messages API 格式,直接透传 //! - **openai_chat**: OpenAI Chat Completions 格式,需要 Anthropic ↔ OpenAI 转换 //! - **openai_responses**: OpenAI Responses API 格式,需要 Anthropic ↔ Responses 转换 +//! - **gemini_native**: Google Gemini Native generateContent 格式,需要 Anthropic ↔ Gemini 转换 //! //! ## 认证模式 //! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version) @@ -35,6 +36,7 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str { return match api_format { "openai_chat" => "openai_chat", "openai_responses" => "openai_responses", + "gemini_native" => "gemini_native", _ => "anthropic", }; } @@ -49,6 +51,7 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str { return match api_format { "openai_chat" => "openai_chat", "openai_responses" => "openai_responses", + "gemini_native" => "gemini_native", _ => "anthropic", }; } @@ -73,13 +76,18 @@ pub fn get_claude_api_format(provider: &Provider) -> &'static str { } pub fn claude_api_format_needs_transform(api_format: &str) -> bool { - matches!(api_format, "openai_chat" | "openai_responses") + matches!( + api_format, + "openai_chat" | "openai_responses" | "gemini_native" + ) } pub fn transform_claude_request_for_api_format( body: serde_json::Value, provider: &Provider, api_format: &str, + session_id: Option<&str>, + shadow_store: Option<&super::gemini_shadow::GeminiShadowStore>, ) -> Result { // Copilot 场景:优先从 metadata.user_id 提取 session ID 作为 cache key // 格式: "uuid_sessionId" → 提取 "_" 后面的部分作为 session 标识 @@ -138,7 +146,24 @@ pub fn transform_claude_request_for_api_format( is_codex_oauth, ) } - "openai_chat" => super::transform::anthropic_to_openai(body), + "openai_chat" => { + let mut result = super::transform::anthropic_to_openai(body)?; + // Inject prompt_cache_key only if explicitly configured in meta + if let Some(key) = provider + .meta + .as_ref() + .and_then(|m| m.prompt_cache_key.as_deref()) + { + result["prompt_cache_key"] = serde_json::json!(key); + } + Ok(result) + } + "gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow( + body, + shadow_store, + Some(&provider.id), + session_id, + ), _ => Ok(body), } } @@ -160,6 +185,16 @@ impl ClaudeAdapter { /// - ClaudeAuth: auth_mode 为 bearer_only /// - Claude: 默认 Anthropic 官方 pub fn provider_type(&self, provider: &Provider) -> ProviderType { + // 检测 Gemini Native 格式 + if self.get_api_format(provider) == "gemini_native" { + return match self.extract_key(provider) { + Some(key) if key.starts_with("ya29.") || key.starts_with('{') => { + ProviderType::GeminiCli + } + _ => ProviderType::Gemini, + }; + } + // 检测 Codex OAuth (ChatGPT Plus/Pro) if self.is_codex_oauth(provider) { return ProviderType::CodexOAuth; @@ -262,6 +297,7 @@ impl ClaudeAdapter { if let Some(key) = env .get("ANTHROPIC_AUTH_TOKEN") .and_then(|v| v.as_str()) + .map(str::trim) .filter(|s| !s.is_empty()) { log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN"); @@ -270,6 +306,7 @@ impl ClaudeAdapter { if let Some(key) = env .get("ANTHROPIC_API_KEY") .and_then(|v| v.as_str()) + .map(str::trim) .filter(|s| !s.is_empty()) { log::debug!("[Claude] 使用 ANTHROPIC_API_KEY"); @@ -279,6 +316,7 @@ impl ClaudeAdapter { if let Some(key) = env .get("OPENROUTER_API_KEY") .and_then(|v| v.as_str()) + .map(str::trim) .filter(|s| !s.is_empty()) { log::debug!("[Claude] 使用 OPENROUTER_API_KEY"); @@ -288,6 +326,7 @@ impl ClaudeAdapter { if let Some(key) = env .get("OPENAI_API_KEY") .and_then(|v| v.as_str()) + .map(str::trim) .filter(|s| !s.is_empty()) { log::debug!("[Claude] 使用 OPENAI_API_KEY"); @@ -301,6 +340,7 @@ impl ClaudeAdapter { .get("apiKey") .or_else(|| provider.settings_config.get("api_key")) .and_then(|v| v.as_str()) + .map(str::trim) .filter(|s| !s.is_empty()) { log::debug!("[Claude] 使用 apiKey/api_key"); @@ -388,14 +428,43 @@ impl ProviderAdapter for ClaudeAdapter { )); } - let strategy = match provider_type { - ProviderType::OpenRouter => AuthStrategy::Bearer, - ProviderType::ClaudeAuth => AuthStrategy::ClaudeAuth, - _ => AuthStrategy::Anthropic, - }; + let key = self.extract_key(provider)?; - self.extract_key(provider) - .map(|key| AuthInfo::new(key, strategy)) + match provider_type { + ProviderType::GeminiCli => { + // Parse stored OAuth JSON and only attach access_token when + // it's actually usable. `parse_oauth_credentials` accepts + // refresh-token-only JSON (which is legitimate before the + // first refresh) and also surfaces `{"access_token": "", ...}` + // for expired credentials. In both cases we would otherwise + // send `Authorization: Bearer ` to upstream and get a 401. + // + // CC Switch does not currently exchange the refresh_token for + // a fresh access_token. Until that path exists, degrade to + // plain GoogleOAuth strategy (which still sends the raw key + // as a fallback) and log loudly so users know to refresh + // their `~/.gemini/oauth_creds.json`. + match super::gemini::GeminiAdapter::new().parse_oauth_credentials(&key) { + Some(creds) if !creds.access_token.is_empty() => { + Some(AuthInfo::with_access_token(key, creds.access_token)) + } + Some(_) => { + log::warn!( + "[Gemini OAuth] access_token missing or empty for provider `{}`; \ + bearer auth will likely fail with 401. Refresh \ + ~/.gemini/oauth_creds.json via the gemini CLI to obtain a new token.", + provider.id + ); + Some(AuthInfo::new(key, AuthStrategy::GoogleOAuth)) + } + None => Some(AuthInfo::new(key, AuthStrategy::GoogleOAuth)), + } + } + ProviderType::Gemini => Some(AuthInfo::new(key, AuthStrategy::Google)), + ProviderType::OpenRouter => Some(AuthInfo::new(key, AuthStrategy::Bearer)), + ProviderType::ClaudeAuth => Some(AuthInfo::new(key, AuthStrategy::ClaudeAuth)), + _ => Some(AuthInfo::new(key, AuthStrategy::Anthropic)), + } } fn build_url(&self, base_url: &str, endpoint: &str) -> String { @@ -437,6 +506,23 @@ impl ProviderAdapter for ClaudeAdapter { HeaderValue::from_str(&bearer).unwrap(), )] } + AuthStrategy::Google => vec![( + HeaderName::from_static("x-goog-api-key"), + HeaderValue::from_str(&auth.api_key).unwrap(), + )], + AuthStrategy::GoogleOAuth => { + let token = auth.access_token.as_ref().unwrap_or(&auth.api_key); + vec![ + ( + HeaderName::from_static("authorization"), + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ), + ( + HeaderName::from_static("x-goog-api-client"), + HeaderValue::from_static("GeminiCLI/1.0"), + ), + ] + } AuthStrategy::CodexOAuth => { // 注意:bearer token 由 forwarder 动态注入到 auth.api_key // ChatGPT-Account-Id 由 forwarder 注入额外 header @@ -507,7 +593,6 @@ impl ProviderAdapter for ClaudeAdapter { ), ] } - _ => vec![], } } @@ -528,7 +613,7 @@ impl ProviderAdapter for ClaudeAdapter { // - "openai_responses": 需要 Anthropic ↔ OpenAI Responses API 格式转换 matches!( self.get_api_format(provider), - "openai_chat" | "openai_responses" + "openai_chat" | "openai_responses" | "gemini_native" ) } @@ -537,7 +622,13 @@ impl ProviderAdapter for ClaudeAdapter { body: serde_json::Value, provider: &Provider, ) -> Result { - transform_claude_request_for_api_format(body, provider, self.get_api_format(provider)) + transform_claude_request_for_api_format( + body, + provider, + self.get_api_format(provider), + None, + None, + ) } fn transform_response(&self, body: serde_json::Value) -> Result { @@ -546,7 +637,9 @@ impl ProviderAdapter for ClaudeAdapter { // config, so we can't check api_format here. Instead we rely on the fact that // Responses API always returns "output" while Chat Completions returns "choices". // This is safe because the two formats are structurally disjoint. - if body.get("output").is_some() { + if body.get("candidates").is_some() || body.get("promptFeedback").is_some() { + super::transform_gemini::gemini_to_anthropic(body) + } else if body.get("output").is_some() { super::transform_responses::responses_to_anthropic(body) } else { super::transform::openai_to_anthropic(body) @@ -684,6 +777,146 @@ mod tests { assert_eq!(auth.strategy, AuthStrategy::ClaudeAuth); } + /// Regression: a Gemini OAuth credential JSON that carries only a + /// refresh_token (no active access_token) must not be surfaced as an + /// `AuthInfo` whose bearer would be empty. Without the guard, downstream + /// header injection produces `Authorization: Bearer ` and a deterministic + /// 401 from upstream. + #[test] + fn test_extract_auth_gemini_cli_refresh_only_json_does_not_expose_empty_bearer() { + let adapter = ClaudeAdapter::new(); + let refresh_only_json = + r#"{"refresh_token":"rt-abc","client_id":"cid","client_secret":"cs"}"#; + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com", + "ANTHROPIC_API_KEY": refresh_only_json + } + }), + ProviderMeta { + api_format: Some("gemini_native".to_string()), + ..Default::default() + }, + ); + + let auth = adapter.extract_auth(&provider).unwrap(); + // access_token must not be surfaced as `Some("")` — the OAuth header + // builder uses `access_token.as_ref().unwrap_or(&api_key)`, so a + // `Some("")` would win over the raw key and emit `Bearer `. + assert!( + auth.access_token.as_deref().is_none_or(|t| !t.is_empty()), + "empty access_token leaked into AuthInfo" + ); + assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth); + } + + /// Companion case: a JSON credential with an empty-string `access_token` + /// field (the shape an expired credential can take after partial writes) + /// must degrade the same way. + #[test] + fn test_extract_auth_gemini_cli_empty_access_token_degrades_to_raw_key() { + let adapter = ClaudeAdapter::new(); + let expired_json = r#"{"access_token":"","refresh_token":"rt-abc","client_id":"cid","client_secret":"cs"}"#; + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com", + "ANTHROPIC_API_KEY": expired_json + } + }), + ProviderMeta { + api_format: Some("gemini_native".to_string()), + ..Default::default() + }, + ); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert!( + auth.access_token.as_deref().is_none_or(|t| !t.is_empty()), + "empty access_token leaked into AuthInfo" + ); + assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth); + } + + /// Counter-case: a well-formed JSON credential with a non-empty + /// access_token must still flow through the OAuth path unchanged. + #[test] + fn test_extract_auth_gemini_cli_valid_json_keeps_access_token() { + let adapter = ClaudeAdapter::new(); + let valid_json = r#"{"access_token":"ya29.valid","refresh_token":"rt"}"#; + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com", + "ANTHROPIC_API_KEY": valid_json + } + }), + ProviderMeta { + api_format: Some("gemini_native".to_string()), + ..Default::default() + }, + ); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.access_token.as_deref(), Some("ya29.valid")); + assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth); + } + + /// 回归:从 oauth_creds.json 复制时常带前导换行/空格。未 trim 时 + /// `starts_with('{')` 会落空,导致误分类为 `ProviderType::Gemini`,再 + /// 以 raw JSON 当 `x-goog-api-key` 发出去触发 401。trim 应在 provider + /// 类型判定和 OAuth 解析前统一生效。 + #[test] + fn test_extract_auth_gemini_cli_json_with_leading_whitespace_classifies_correctly() { + let adapter = ClaudeAdapter::new(); + let valid_json = r#"{"access_token":"ya29.valid","refresh_token":"rt"}"#; + let key_with_whitespace = format!("\n {valid_json}\n"); + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com", + "ANTHROPIC_API_KEY": key_with_whitespace + } + }), + ProviderMeta { + api_format: Some("gemini_native".to_string()), + ..Default::default() + }, + ); + + assert_eq!(adapter.provider_type(&provider), ProviderType::GeminiCli); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.access_token.as_deref(), Some("ya29.valid")); + assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth); + } + + /// 回归:裸 `ya29.` access_token 若带前导换行,也应被 trim 后识别为 + /// Gemini CLI OAuth,避免前导空白把 `starts_with("ya29.")` 检查顶穿。 + #[test] + fn test_extract_auth_gemini_cli_access_token_with_leading_newline_classifies_correctly() { + let adapter = ClaudeAdapter::new(); + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com", + "ANTHROPIC_API_KEY": "\nya29.raw-token-value\n" + } + }), + ProviderMeta { + api_format: Some("gemini_native".to_string()), + ..Default::default() + }, + ); + + assert_eq!(adapter.provider_type(&provider), ProviderType::GeminiCli); + + let auth = adapter.extract_auth(&provider).unwrap(); + assert_eq!(auth.access_token.as_deref(), Some("ya29.raw-token-value")); + assert_eq!(auth.strategy, AuthStrategy::GoogleOAuth); + } + #[test] fn test_provider_type_detection() { let adapter = ClaudeAdapter::new(); @@ -850,6 +1083,24 @@ mod tests { ); assert!(adapter.needs_transform(&openai_responses_provider)); + let gemini_native_provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com", + "ANTHROPIC_API_KEY": "test-key" + } + }), + ProviderMeta { + api_format: Some("gemini_native".to_string()), + ..Default::default() + }, + ); + assert!(adapter.needs_transform(&gemini_native_provider)); + assert_eq!( + adapter.provider_type(&gemini_native_provider), + ProviderType::Gemini + ); + // meta takes precedence over legacy settings_config fields let meta_precedence_over_settings = create_provider_with_meta( json!({ @@ -957,11 +1208,106 @@ mod tests { "max_tokens": 128 }); - let transformed = - transform_claude_request_for_api_format(body, &provider, "openai_responses").unwrap(); + let transformed = transform_claude_request_for_api_format( + body, + &provider, + "openai_responses", + None, + None, + ) + .unwrap(); assert_eq!(transformed["model"], "gpt-5.4"); assert!(transformed.get("input").is_some()); assert!(transformed.get("max_output_tokens").is_some()); } + + #[test] + fn test_transform_claude_request_for_api_format_gemini_native() { + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://generativelanguage.googleapis.com", + "ANTHROPIC_API_KEY": "test-key" + } + }), + ProviderMeta { + api_format: Some("gemini_native".to_string()), + ..Default::default() + }, + ); + let body = json!({ + "model": "gemini-2.5-pro", + "system": "You are helpful.", + "messages": [{ "role": "user", "content": "hello" }], + "max_tokens": 64 + }); + + let transformed = + transform_claude_request_for_api_format(body, &provider, "gemini_native", None, None) + .unwrap(); + + assert!(transformed.get("contents").is_some()); + assert_eq!( + transformed["systemInstruction"]["parts"][0]["text"], + "You are helpful." + ); + assert_eq!(transformed["generationConfig"]["maxOutputTokens"], 64); + } + + #[test] + fn test_transform_claude_request_for_api_format_openai_chat_skips_prompt_cache_key_by_default() + { + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com", + "ANTHROPIC_API_KEY": "test-key" + } + }), + ProviderMeta { + api_format: Some("openai_chat".to_string()), + ..Default::default() + }, + ); + let body = json!({ + "model": "gpt-5.4", + "messages": [{ "role": "user", "content": "hello" }], + "max_tokens": 64 + }); + + let transformed = + transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None) + .unwrap(); + + assert!(transformed.get("prompt_cache_key").is_none()); + } + + #[test] + fn test_transform_claude_request_for_api_format_openai_chat_keeps_explicit_prompt_cache_key() { + let provider = create_provider_with_meta( + json!({ + "env": { + "ANTHROPIC_BASE_URL": "https://api.example.com", + "ANTHROPIC_API_KEY": "test-key" + } + }), + ProviderMeta { + api_format: Some("openai_chat".to_string()), + prompt_cache_key: Some("claude-cache-route".to_string()), + ..Default::default() + }, + ); + let body = json!({ + "model": "gpt-5.4", + "messages": [{ "role": "user", "content": "hello" }], + "max_tokens": 64 + }); + + let transformed = + transform_claude_request_for_api_format(body, &provider, "openai_chat", None, None) + .unwrap(); + + assert_eq!(transformed["prompt_cache_key"], "claude-cache-route"); + } } diff --git a/src-tauri/src/proxy/providers/gemini.rs b/src-tauri/src/proxy/providers/gemini.rs index e0e88feb8..7de152a97 100644 --- a/src-tauri/src/proxy/providers/gemini.rs +++ b/src-tauri/src/proxy/providers/gemini.rs @@ -70,6 +70,11 @@ impl GeminiAdapter { /// 解析 OAuth 凭证 pub fn parse_oauth_credentials(&self, key: &str) -> Option { + // 防御性 trim:前端在 input 事件中会 trim,但 JSON 编辑器 / deeplink + // 导入 / live 回填等路径会绕过。带前导换行的 oauth_creds.json 粘贴 + // 是常见场景,此处统一兜底。 + let key = key.trim(); + // 直接是 access_token if key.starts_with("ya29.") { return Some(OAuthCredentials { @@ -120,7 +125,12 @@ impl GeminiAdapter { fn extract_key_raw(&self, provider: &Provider) -> Option { if let Some(env) = provider.settings_config.get("env") { // 使用 GEMINI_API_KEY - if let Some(key) = env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) { + if let Some(key) = env + .get("GEMINI_API_KEY") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + { return Some(key.to_string()); } } @@ -131,6 +141,8 @@ impl GeminiAdapter { .get("apiKey") .or_else(|| provider.settings_config.get("api_key")) .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) { return Some(key.to_string()); } diff --git a/src-tauri/src/proxy/providers/gemini_schema.rs b/src-tauri/src/proxy/providers/gemini_schema.rs new file mode 100644 index 000000000..704816ef8 --- /dev/null +++ b/src-tauri/src/proxy/providers/gemini_schema.rs @@ -0,0 +1,338 @@ +//! Gemini tool schema helpers. +//! +//! Gemini `FunctionDeclaration` supports two schema channels: +//! - `parameters`: a restricted `Schema` subset +//! - `parametersJsonSchema`: richer JSON Schema via arbitrary JSON `Value` +//! +//! Anthropic tool schemas are closer to JSON Schema, so we choose the richer +//! channel when unsupported `Schema` fields are present. + +use serde_json::{json, Map, Value}; + +#[derive(Debug, Clone, PartialEq)] +pub enum GeminiFunctionParameters { + Schema(Value), + JsonSchema(Value), +} + +pub fn build_gemini_function_parameters(input_schema: Value) -> GeminiFunctionParameters { + let schema = ensure_object_schema(normalize_json_schema(input_schema)); + + if requires_parameters_json_schema(&schema) { + GeminiFunctionParameters::JsonSchema(schema) + } else { + GeminiFunctionParameters::Schema(to_gemini_schema(schema)) + } +} + +/// Vertex AI rejects FunctionDeclarations whose `parameters` schema lacks an +/// explicit `type: "object"`, returning: +/// +/// > functionDeclaration parameters schema should be of type OBJECT. +/// +/// Anthropic tools sometimes arrive with empty or type-less `input_schema` +/// (e.g. no-argument tools like Claude Code's `TodoRead`). Normalize those to +/// `{type: "object", properties: {}}` so the Gemini upstream accepts them. +/// +/// References: google-gemini/generative-ai-python#423, BerriAI/litellm#5055. +fn ensure_object_schema(schema: Value) -> Value { + match schema { + Value::Object(mut obj) => { + obj.entry("type".to_string()) + .or_insert_with(|| json!("object")); + if obj.get("type").and_then(|v| v.as_str()) == Some("object") { + obj.entry("properties".to_string()) + .or_insert_with(|| json!({})); + } + Value::Object(obj) + } + other => other, + } +} + +fn normalize_json_schema(schema: Value) -> Value { + match schema { + Value::Object(mut obj) => { + obj.remove("$schema"); + obj.remove("$id"); + + if let Some(properties) = obj + .get_mut("properties") + .and_then(|value| value.as_object_mut()) + { + for value in properties.values_mut() { + *value = normalize_json_schema(value.clone()); + } + } + + if let Some(items) = obj.get_mut("items") { + *items = normalize_json_schema(items.clone()); + } + + for key in ["anyOf", "oneOf", "allOf", "prefixItems"] { + if let Some(values) = obj.get_mut(key).and_then(|value| value.as_array_mut()) { + for value in values.iter_mut() { + *value = normalize_json_schema(value.clone()); + } + } + } + + for key in ["not", "if", "then", "else", "additionalProperties"] { + if let Some(value) = obj.get_mut(key) { + *value = normalize_json_schema(value.clone()); + } + } + + Value::Object(obj) + } + Value::Array(values) => { + Value::Array(values.into_iter().map(normalize_json_schema).collect()) + } + other => other, + } +} + +fn requires_parameters_json_schema(schema: &Value) -> bool { + match schema { + Value::Object(obj) => object_requires_parameters_json_schema(obj), + Value::Array(values) => values.iter().any(requires_parameters_json_schema), + _ => false, + } +} + +fn object_requires_parameters_json_schema(obj: &Map) -> bool { + for (key, value) in obj { + match key.as_str() { + "type" => { + if value.is_array() { + return true; + } + } + "format" | "title" | "description" | "nullable" | "enum" | "maxItems" | "minItems" + | "required" | "minProperties" | "maxProperties" | "minLength" | "maxLength" + | "pattern" | "example" | "propertyOrdering" | "default" | "minimum" | "maximum" => {} + "properties" => { + let Some(properties) = value.as_object() else { + return true; + }; + if properties.values().any(requires_parameters_json_schema) { + return true; + } + } + "items" => { + if !value.is_object() || requires_parameters_json_schema(value) { + return true; + } + } + "anyOf" => { + let Some(values) = value.as_array() else { + return true; + }; + if values.iter().any(requires_parameters_json_schema) { + return true; + } + } + // JSON Schema keywords that Gemini `parameters` does not accept. + "$ref" + | "$defs" + | "definitions" + | "additionalProperties" + | "unevaluatedProperties" + | "patternProperties" + | "oneOf" + | "allOf" + | "const" + | "not" + | "if" + | "then" + | "else" + | "dependentRequired" + | "dependentSchemas" + | "contains" + | "minContains" + | "maxContains" + | "prefixItems" + | "exclusiveMinimum" + | "exclusiveMaximum" + | "multipleOf" + | "examples" => return true, + // Be conservative for unknown keywords. + _ => return true, + } + } + + false +} + +fn to_gemini_schema(schema: Value) -> Value { + match schema { + Value::Object(obj) => { + let mut result = Map::new(); + + for (key, value) in obj { + match key.as_str() { + "type" | "format" | "title" | "description" | "nullable" | "enum" + | "maxItems" | "minItems" | "required" | "minProperties" | "maxProperties" + | "minLength" | "maxLength" | "pattern" | "example" | "propertyOrdering" + | "default" | "minimum" | "maximum" => { + result.insert(key, value); + } + "properties" => { + if let Some(properties) = value.as_object() { + let converted = properties + .iter() + .map(|(name, property_schema)| { + (name.clone(), to_gemini_schema(property_schema.clone())) + }) + .collect(); + result.insert("properties".to_string(), Value::Object(converted)); + } + } + "items" if value.is_object() => { + result.insert("items".to_string(), to_gemini_schema(value)); + } + "anyOf" => { + if let Some(values) = value.as_array() { + result.insert( + "anyOf".to_string(), + Value::Array( + values + .iter() + .map(|value| to_gemini_schema(value.clone())) + .collect(), + ), + ); + } + } + _ => {} + } + } + + Value::Object(result) + } + other => other, + } +} + +pub fn build_gemini_function_declaration( + name: &str, + description: Option<&str>, + input_schema: Value, +) -> Value { + let mut declaration = Map::new(); + declaration.insert("name".to_string(), json!(name)); + declaration.insert("description".to_string(), json!(description.unwrap_or(""))); + + match build_gemini_function_parameters(input_schema) { + GeminiFunctionParameters::Schema(schema) => { + declaration.insert("parameters".to_string(), schema); + } + GeminiFunctionParameters::JsonSchema(schema) => { + declaration.insert("parametersJsonSchema".to_string(), schema); + } + } + + Value::Object(declaration) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uses_schema_for_simple_openapi_subset() { + let schema = json!({ + "type": "object", + "properties": { + "city": { "type": "string", "description": "Target city" } + }, + "required": ["city"] + }); + + let result = build_gemini_function_declaration("weather", Some("Weather lookup"), schema); + + assert!(result.get("parameters").is_some()); + assert!(result.get("parametersJsonSchema").is_none()); + assert_eq!(result["parameters"]["properties"]["city"]["type"], "string"); + } + + #[test] + fn uses_parameters_json_schema_for_additional_properties() { + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "city": { "type": "string" } + }, + "required": ["city"], + "additionalProperties": false + }); + + let result = build_gemini_function_declaration("weather", Some("Weather lookup"), schema); + + assert!(result.get("parameters").is_none()); + assert!(result.get("parametersJsonSchema").is_some()); + assert!(result["parametersJsonSchema"].get("$schema").is_none()); + assert_eq!( + result["parametersJsonSchema"]["additionalProperties"], + false + ); + } + + #[test] + fn uses_parameters_json_schema_for_one_of() { + let schema = json!({ + "type": "object", + "properties": { + "target": { + "oneOf": [ + { "type": "string" }, + { "type": "integer" } + ] + } + } + }); + + let result = build_gemini_function_declaration("search", Some("Search"), schema); + + assert!(result.get("parameters").is_none()); + assert!(result.get("parametersJsonSchema").is_some()); + } + + /// Regression for P2 (Vertex AI rejecting empty schemas): zero-argument + /// Anthropic tools (no `input_schema`) must produce `parameters` with an + /// explicit `type: "object"` and an empty `properties` map so the Gemini + /// upstream does not return `schema should be of type OBJECT`. + #[test] + fn empty_input_schema_produces_explicit_object_type() { + let result = build_gemini_function_declaration("ping", Some("no-arg"), json!({})); + + assert_eq!(result["parameters"]["type"], "object"); + assert!(result["parameters"]["properties"].is_object()); + } + + /// A schema that carries descriptive fields but no `type` is still a + /// zero-arg object for Gemini purposes — promote it explicitly. + #[test] + fn input_schema_missing_type_is_promoted_to_object() { + let result = build_gemini_function_declaration( + "noop", + None, + json!({ "description": "does nothing" }), + ); + + assert_eq!(result["parameters"]["type"], "object"); + assert!(result["parameters"]["properties"].is_object()); + } + + /// Defensive: an atomic (non-object) schema is left untouched, because + /// forcing `type: "object"` here would corrupt primitive parameter types + /// that happen to flow through this path. + #[test] + fn non_object_schema_is_not_mutated() { + let result = build_gemini_function_declaration("bare", None, json!({ "type": "string" })); + + assert_eq!(result["parameters"]["type"], "string"); + assert!(result["parameters"].get("properties").is_none()); + } +} diff --git a/src-tauri/src/proxy/providers/gemini_shadow.rs b/src-tauri/src/proxy/providers/gemini_shadow.rs new file mode 100644 index 000000000..f1f5d6d39 --- /dev/null +++ b/src-tauri/src/proxy/providers/gemini_shadow.rs @@ -0,0 +1,389 @@ +//! Gemini Native shadow state +//! +//! Keeps provider/session-scoped assistant content snapshots and tool call metadata +//! so Gemini thought signatures and tool turns can be replayed without bloating +//! the main proxy files. + +use serde_json::Value; +use std::collections::{HashMap, VecDeque}; +use std::sync::RwLock; + +/// Composite key for a Gemini shadow session. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct GeminiShadowKey { + pub provider_id: String, + pub session_id: String, +} + +impl GeminiShadowKey { + pub fn new(provider_id: impl Into, session_id: impl Into) -> Self { + Self { + provider_id: provider_id.into(), + session_id: session_id.into(), + } + } +} + +/// Gemini function call metadata captured from an assistant turn. +#[derive(Debug, Clone, PartialEq)] +pub struct GeminiToolCallMeta { + pub id: Option, + pub name: String, + pub args: Value, + pub thought_signature: Option, +} + +impl GeminiToolCallMeta { + pub fn new( + id: Option>, + name: impl Into, + args: Value, + thought_signature: Option>, + ) -> Self { + Self { + id: id.map(Into::into), + name: name.into(), + args, + thought_signature: thought_signature.map(Into::into), + } + } +} + +/// Stored assistant turn snapshot. +#[derive(Debug, Clone, PartialEq)] +pub struct GeminiAssistantTurn { + pub assistant_content: Value, + pub tool_calls: Vec, +} + +impl GeminiAssistantTurn { + pub fn new(assistant_content: Value, tool_calls: Vec) -> Self { + Self { + assistant_content, + tool_calls, + } + } +} + +/// Session snapshot returned by read APIs. +#[derive(Debug, Clone, PartialEq)] +pub struct GeminiShadowSessionSnapshot { + pub provider_id: String, + pub session_id: String, + pub turns: Vec, +} + +#[derive(Debug, Clone)] +struct GeminiShadowSession { + turns: VecDeque, +} + +impl GeminiShadowSession { + fn new() -> Self { + Self { + turns: VecDeque::new(), + } + } +} + +#[derive(Debug, Clone)] +struct GeminiShadowInner { + sessions: HashMap, + session_order: VecDeque, +} + +impl GeminiShadowInner { + fn new() -> Self { + Self { + sessions: HashMap::new(), + session_order: VecDeque::new(), + } + } +} + +/// Thread-safe shadow store for Gemini Native replay state. +/// +/// The store is intentionally small and explicit: +/// - sessions are keyed by `(provider_id, session_id)` +/// - each session keeps only a bounded number of recent assistant turns +/// - the oldest session is evicted first when the store is full +#[derive(Debug)] +pub struct GeminiShadowStore { + max_sessions: usize, + max_turns_per_session: usize, + inner: RwLock, +} + +impl Default for GeminiShadowStore { + fn default() -> Self { + Self::with_limits(200, 64) + } +} + +impl GeminiShadowStore { + #[allow(dead_code)] + pub fn new() -> Self { + Self::default() + } + + pub fn with_limits(max_sessions: usize, max_turns_per_session: usize) -> Self { + Self { + max_sessions: max_sessions.max(1), + max_turns_per_session: max_turns_per_session.max(1), + inner: RwLock::new(GeminiShadowInner::new()), + } + } + + /// Record a Gemini assistant turn for later replay. + pub fn record_assistant_turn( + &self, + provider_id: impl Into, + session_id: impl Into, + assistant_content: Value, + tool_calls: Vec, + ) -> GeminiShadowSessionSnapshot { + let key = GeminiShadowKey::new(provider_id, session_id); + let turn = GeminiAssistantTurn::new(assistant_content, tool_calls); + + let mut inner = self.inner.write().expect("gemini shadow lock poisoned"); + Self::touch_session_order(&mut inner.session_order, &key); + + let snapshot = { + let session = inner + .sessions + .entry(key.clone()) + .or_insert_with(GeminiShadowSession::new); + session.turns.push_back(turn); + while session.turns.len() > self.max_turns_per_session { + session.turns.pop_front(); + } + Self::snapshot_session(&key, session) + }; + Self::prune_sessions(&mut inner, self.max_sessions); + snapshot + } + + /// Get the latest assistant content for a provider/session pair. + #[allow(dead_code)] + pub fn latest_assistant_content(&self, provider_id: &str, session_id: &str) -> Option { + self.get_session(provider_id, session_id) + .and_then(|snapshot| { + snapshot + .turns + .last() + .map(|turn| turn.assistant_content.clone()) + }) + } + + /// Get the latest tool calls for a provider/session pair. + #[allow(dead_code)] + pub fn latest_tool_calls( + &self, + provider_id: &str, + session_id: &str, + ) -> Option> { + self.get_session(provider_id, session_id) + .and_then(|snapshot| snapshot.turns.last().map(|turn| turn.tool_calls.clone())) + } + + /// Read a full session snapshot. + pub fn get_session( + &self, + provider_id: &str, + session_id: &str, + ) -> Option { + let key = GeminiShadowKey::new(provider_id, session_id); + let mut inner = self.inner.write().expect("gemini shadow lock poisoned"); + let snapshot = inner + .sessions + .get(&key) + .map(|session| Self::snapshot_session(&key, session)); + if snapshot.is_some() { + Self::touch_session_order(&mut inner.session_order, &key); + } + snapshot + } + + /// Remove a single session from the store. + #[allow(dead_code)] + pub fn clear_session(&self, provider_id: &str, session_id: &str) -> bool { + let key = GeminiShadowKey::new(provider_id, session_id); + let mut inner = self.inner.write().expect("gemini shadow lock poisoned"); + let removed = inner.sessions.remove(&key).is_some(); + if removed { + Self::remove_key_from_order(&mut inner.session_order, &key); + } + removed + } + + /// Remove all sessions for a provider. + #[allow(dead_code)] + pub fn clear_provider(&self, provider_id: &str) -> usize { + let mut inner = self.inner.write().expect("gemini shadow lock poisoned"); + let keys: Vec<_> = inner + .sessions + .keys() + .filter(|key| key.provider_id == provider_id) + .cloned() + .collect(); + for key in &keys { + inner.sessions.remove(key); + Self::remove_key_from_order(&mut inner.session_order, key); + } + keys.len() + } + + /// Number of tracked sessions. + #[allow(dead_code)] + pub fn session_count(&self) -> usize { + self.inner + .read() + .expect("gemini shadow lock poisoned") + .sessions + .len() + } + + fn snapshot_session( + key: &GeminiShadowKey, + session: &GeminiShadowSession, + ) -> GeminiShadowSessionSnapshot { + GeminiShadowSessionSnapshot { + provider_id: key.provider_id.clone(), + session_id: key.session_id.clone(), + turns: session.turns.iter().cloned().collect(), + } + } + + fn touch_session_order(order: &mut VecDeque, key: &GeminiShadowKey) { + if let Some(pos) = order.iter().position(|existing| existing == key) { + order.remove(pos); + } + order.push_back(key.clone()); + } + + #[allow(dead_code)] + fn remove_key_from_order(order: &mut VecDeque, key: &GeminiShadowKey) { + if let Some(pos) = order.iter().position(|existing| existing == key) { + order.remove(pos); + } + } + + fn prune_sessions(inner: &mut GeminiShadowInner, max_sessions: usize) { + while inner.sessions.len() > max_sessions { + let Some(evicted_key) = inner.session_order.pop_front() else { + break; + }; + inner.sessions.remove(&evicted_key); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn record_and_read_latest_turn() { + let store = GeminiShadowStore::with_limits(8, 4); + let snapshot = store.record_assistant_turn( + "provider-a", + "session-1", + json!({"parts": [{"text": "hello", "thoughtSignature": "sig-1"}]}), + vec![GeminiToolCallMeta::new( + Some("call-1"), + "get_weather", + json!({"location": "Tokyo"}), + Some("sig-1"), + )], + ); + + assert_eq!(snapshot.provider_id, "provider-a"); + assert_eq!(snapshot.session_id, "session-1"); + assert_eq!(snapshot.turns.len(), 1); + + let content = store + .latest_assistant_content("provider-a", "session-1") + .expect("content"); + assert_eq!(content["parts"][0]["text"], "hello"); + assert_eq!(content["parts"][0]["thoughtSignature"], "sig-1"); + + let tool_calls = store + .latest_tool_calls("provider-a", "session-1") + .expect("tool calls"); + assert_eq!(tool_calls.len(), 1); + assert_eq!(tool_calls[0].id.as_deref(), Some("call-1")); + assert_eq!(tool_calls[0].name, "get_weather"); + assert_eq!(tool_calls[0].args["location"], "Tokyo"); + assert_eq!(tool_calls[0].thought_signature.as_deref(), Some("sig-1")); + } + + #[test] + fn sessions_are_isolated_by_provider_and_session_id() { + let store = GeminiShadowStore::with_limits(8, 4); + + store.record_assistant_turn("provider-a", "session-1", json!({"text": "a"}), vec![]); + store.record_assistant_turn("provider-b", "session-1", json!({"text": "b"}), vec![]); + store.record_assistant_turn("provider-a", "session-2", json!({"text": "c"}), vec![]); + + assert_eq!(store.session_count(), 3); + assert_eq!( + store.latest_assistant_content("provider-a", "session-1"), + Some(json!({"text": "a"})) + ); + assert_eq!( + store.latest_assistant_content("provider-b", "session-1"), + Some(json!({"text": "b"})) + ); + assert_eq!( + store.latest_assistant_content("provider-a", "session-2"), + Some(json!({"text": "c"})) + ); + } + + #[test] + fn retains_only_latest_turns_per_session() { + let store = GeminiShadowStore::with_limits(8, 2); + + store.record_assistant_turn("provider-a", "session-1", json!({"idx": 1}), vec![]); + store.record_assistant_turn("provider-a", "session-1", json!({"idx": 2}), vec![]); + store.record_assistant_turn("provider-a", "session-1", json!({"idx": 3}), vec![]); + + let snapshot = store + .get_session("provider-a", "session-1") + .expect("snapshot"); + assert_eq!(snapshot.turns.len(), 2); + assert_eq!(snapshot.turns[0].assistant_content, json!({"idx": 2})); + assert_eq!(snapshot.turns[1].assistant_content, json!({"idx": 3})); + } + + #[test] + fn evicts_oldest_session_when_capacity_is_exceeded() { + let store = GeminiShadowStore::with_limits(2, 2); + + store.record_assistant_turn("provider-a", "session-1", json!({"idx": 1}), vec![]); + store.record_assistant_turn("provider-a", "session-2", json!({"idx": 2}), vec![]); + store.record_assistant_turn("provider-a", "session-3", json!({"idx": 3}), vec![]); + + assert!(store.get_session("provider-a", "session-1").is_none()); + assert!(store.get_session("provider-a", "session-2").is_some()); + assert!(store.get_session("provider-a", "session-3").is_some()); + } + + #[test] + fn clear_session_and_provider_work() { + let store = GeminiShadowStore::with_limits(8, 4); + + store.record_assistant_turn("provider-a", "session-1", json!({"idx": 1}), vec![]); + store.record_assistant_turn("provider-a", "session-2", json!({"idx": 2}), vec![]); + store.record_assistant_turn("provider-b", "session-3", json!({"idx": 3}), vec![]); + + assert!(store.clear_session("provider-a", "session-1")); + assert!(store.get_session("provider-a", "session-1").is_none()); + + let removed = store.clear_provider("provider-a"); + assert_eq!(removed, 1); + assert!(store.get_session("provider-a", "session-2").is_none()); + assert!(store.get_session("provider-b", "session-3").is_some()); + } +} diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index b6b8bb643..7e6238ec1 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -18,10 +18,14 @@ mod codex; pub mod codex_oauth_auth; pub mod copilot_auth; mod gemini; +pub(crate) mod gemini_schema; +pub mod gemini_shadow; pub mod models; pub mod streaming; +pub mod streaming_gemini; pub mod streaming_responses; pub mod transform; +pub mod transform_gemini; pub mod transform_responses; use crate::app_config::AppType; @@ -101,6 +105,14 @@ impl ProviderType { pub fn from_app_type_and_config(app_type: &AppType, provider: &Provider) -> Self { match app_type { AppType::Claude => { + if get_claude_api_format(provider) == "gemini_native" { + let adapter = ClaudeAdapter::new(); + return match adapter.extract_auth(provider).map(|auth| auth.strategy) { + Some(AuthStrategy::GoogleOAuth) => ProviderType::GeminiCli, + _ => ProviderType::Gemini, + }; + } + // 检测是否为 GitHub Copilot if let Some(meta) = provider.meta.as_ref() { if meta.provider_type.as_deref() == Some("github_copilot") { diff --git a/src-tauri/src/proxy/providers/streaming.rs b/src-tauri/src/proxy/providers/streaming.rs index 2ca647d66..d6339e5d2 100644 --- a/src-tauri/src/proxy/providers/streaming.rs +++ b/src-tauri/src/proxy/providers/streaming.rs @@ -2,7 +2,7 @@ //! //! 实现 OpenAI SSE → Anthropic SSE 格式转换 -use crate::proxy::sse::strip_sse_field; +use crate::proxy::sse::{strip_sse_field, take_sse_block}; use bytes::Bytes; use futures::stream::{Stream, StreamExt}; use serde::{Deserialize, Serialize}; @@ -118,10 +118,7 @@ pub fn create_anthropic_sse_stream( Ok(bytes) => { crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes); - while let Some(pos) = buffer.find("\n\n") { - let line = buffer[..pos].to_string(); - buffer = buffer[pos + 2..].to_string(); - + while let Some(line) = take_sse_block(&mut buffer) { if line.trim().is_empty() { continue; } diff --git a/src-tauri/src/proxy/providers/streaming_gemini.rs b/src-tauri/src/proxy/providers/streaming_gemini.rs new file mode 100644 index 000000000..815c19ed1 --- /dev/null +++ b/src-tauri/src/proxy/providers/streaming_gemini.rs @@ -0,0 +1,1054 @@ +//! Gemini Native streaming conversion module. +//! +//! Converts Gemini `streamGenerateContent?alt=sse` chunks into Anthropic-style +//! SSE events for Claude-compatible clients. + +use super::gemini_shadow::{GeminiShadowStore, GeminiToolCallMeta}; +use super::transform_gemini::{ + build_anthropic_usage, is_synthesized_tool_call_id, rectify_tool_call_parts, + synthesize_tool_call_id, AnthropicToolSchemaHints, +}; +use crate::proxy::sse::{append_utf8_safe, strip_sse_field, take_sse_block}; +use bytes::Bytes; +use futures::stream::{Stream, StreamExt}; +use serde_json::{json, Value}; +use std::collections::HashSet; +use std::sync::Arc; + +fn map_finish_reason(reason: Option<&str>, has_tool_use: bool, blocked: bool) -> &'static str { + if blocked { + return "refusal"; + } + + match reason { + Some("MAX_TOKENS") => "max_tokens", + Some("SAFETY") + | Some("RECITATION") + | Some("SPII") + | Some("BLOCKLIST") + | Some("PROHIBITED_CONTENT") => "refusal", + _ if has_tool_use => "tool_use", + _ => "end_turn", + } +} + +fn extract_visible_text(parts: &[Value]) -> String { + parts + .iter() + .filter(|part| part.get("thought").and_then(|value| value.as_bool()) != Some(true)) + .filter_map(|part| part.get("text").and_then(|value| value.as_str())) + .collect::() +} + +fn extract_tool_calls( + parts: &[Value], + tool_schema_hints: Option<&AnthropicToolSchemaHints>, +) -> Vec { + let mut rectified_parts = parts.to_vec(); + rectify_tool_call_parts(&mut rectified_parts, tool_schema_hints); + + rectified_parts + .iter() + .filter_map(|part| { + let function_call = part.get("functionCall")?; + // Treat an explicit empty-string id as equivalent to a missing + // one. Some Gemini relays serialize absent ids as `"id": ""`; + // without this filter the `Some("")` value would flow into + // `merge_tool_call_snapshots`, match itself across chunks, and + // collapse parallel no-id calls into a single snapshot with an + // empty-string tool_use id. + let id = function_call + .get("id") + .and_then(|value| value.as_str()) + .filter(|s| !s.is_empty()) + .map(ToString::to_string); + Some(GeminiToolCallMeta::new( + id, + function_call + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or(""), + function_call + .get("args") + .cloned() + .unwrap_or_else(|| json!({})), + part.get("thoughtSignature") + .or_else(|| part.get("thought_signature")) + .and_then(|value| value.as_str()), + )) + }) + .collect() +} + +fn extract_text_thought_signature(parts: &[Value]) -> Option { + parts + .iter() + .filter(|part| part.get("text").is_some() && part.get("functionCall").is_none()) + .filter_map(|part| { + part.get("thoughtSignature") + .or_else(|| part.get("thought_signature")) + .and_then(|value| value.as_str()) + }) + .next_back() + .map(ToString::to_string) +} + +fn merge_tool_call_snapshots( + tool_call_snapshots: &mut Vec, + incoming: Vec, +) { + // Gemini's `streamGenerateContent?alt=sse` delivers each chunk as the + // cumulative snapshot of `content.parts`. For the same tool call across + // chunks we therefore need to map an incoming entry back to whichever + // snapshot entry it describes: + // + // 1. If both sides carry a genuine Gemini id, match by id. + // 2. Otherwise match by position in the cumulative `parts` array — this + // is how parallel no-id calls stay distinguishable. + // + // A previous implementation fell back to matching by `name`, which silently + // merged two parallel calls to the same function into one entry (losing + // the first call's args). That fallback is removed here. + for (position, mut tool_call) in incoming.into_iter().enumerate() { + // Treat an empty-string id as "missing" throughout this function. + // `extract_tool_calls` already filters `""` at the source, but upstream + // callers that build `GeminiToolCallMeta` by hand (tests, future code) + // could still send `Some("")` — collapsing it here keeps the invariant + // local to this merge step. + if tool_call.id.as_deref() == Some("") { + tool_call.id = None; + } + + let existing_index = match tool_call.id.as_deref() { + Some(incoming_id) => tool_call_snapshots + .iter() + .position(|existing| existing.id.as_deref() == Some(incoming_id)) + .or_else(|| { + // Fallback for the "synth -> real id upgrade" case: + // Gemini's cumulative stream may deliver the first chunk + // of a tool call without an id (we synthesize one) and + // then upgrade it to a genuine id on a later chunk. A + // pure id-match would miss the existing synthesized + // snapshot and push a second entry, yielding duplicate + // `tool_use` content blocks at stream end. If the + // same-position slot currently holds a synthesized id, + // merge into it — `or(preserved_id)` below will keep + // the real id, dropping the synthesized one. + tool_call_snapshots + .get(position) + .filter(|existing| { + matches!( + existing.id.as_deref(), + Some(id) if is_synthesized_tool_call_id(id) + ) + }) + .map(|_| position) + }), + None => tool_call_snapshots + .get(position) + .filter(|existing| match existing.id.as_deref() { + // Only merge into a positional match when the prior + // snapshot was itself id-less (or we synthesized one). + // A snapshot with a genuine Gemini id at this index is + // treated as a different call — the incoming entry gets + // its own synthesized id below. + Some(id) => is_synthesized_tool_call_id(id), + None => true, + }) + .map(|_| position), + }; + + if let Some(index) = existing_index { + // Preserve any synthesized id assigned on a previous chunk so the + // Anthropic-visible id stays stable across the whole stream. + // When incoming carries a real Gemini id and the slot holds a + // synthesized one, `Some(real).or(Some(synth)) == Some(real)` + // so the upgrade wins naturally. + let preserved_id = tool_call_snapshots[index].id.clone(); + tool_call.id = tool_call.id.or(preserved_id); + + // Preserve `thought_signature` across chunks. Gemini's cumulative + // stream may include `thoughtSignature` on one chunk and omit it + // on a subsequent cumulative snapshot of the same part, even + // though the signature still belongs to the call. A blind + // overwrite would drop it, so the shadow turn we record (and + // later replay) would be missing `thoughtSignature` and the + // upstream would reject the follow-up for invalid signature. + if tool_call.thought_signature.is_none() { + tool_call + .thought_signature + .clone_from(&tool_call_snapshots[index].thought_signature); + } + } + if tool_call.id.is_none() { + tool_call.id = Some(synthesize_tool_call_id()); + } + + match existing_index { + Some(index) => tool_call_snapshots[index] = tool_call, + None => tool_call_snapshots.push(tool_call), + } + } +} + +fn build_shadow_assistant_parts( + text: Option<&str>, + text_thought_signature: Option<&str>, + tool_calls: &[GeminiToolCallMeta], +) -> Vec { + let mut parts = Vec::new(); + + if text.filter(|text| !text.is_empty()).is_some() || text_thought_signature.is_some() { + let mut part = json!({ + "text": text.unwrap_or("") + }); + if let Some(signature) = text_thought_signature { + part["thoughtSignature"] = json!(signature); + } + parts.push(part); + } + + for tool_call in tool_calls { + let mut part = json!({ + "functionCall": { + "id": tool_call.id.clone().unwrap_or_default(), + "name": tool_call.name, + "args": tool_call.args + } + }); + + if let Some(signature) = &tool_call.thought_signature { + part["thoughtSignature"] = json!(signature); + } + + parts.push(part); + } + + parts +} + +fn encode_sse(event_name: &str, payload: &Value) -> Bytes { + Bytes::from(format!( + "event: {event_name}\ndata: {}\n\n", + serde_json::to_string(payload).unwrap_or_default() + )) +} + +pub fn create_anthropic_sse_stream_from_gemini( + stream: impl Stream> + Send + 'static, + shadow_store: Option>, + provider_id: Option, + session_id: Option, + tool_schema_hints: Option, +) -> impl Stream> + Send { + async_stream::stream! { + let mut buffer = String::new(); + let mut utf8_remainder = Vec::new(); + let mut message_id: Option = None; + let mut current_model: Option = None; + let mut has_sent_message_start = false; + let mut accumulated_text = String::new(); + let mut text_block_index: Option = None; + let mut next_content_index: u32 = 0; + let mut open_indices: HashSet = HashSet::new(); + let mut tool_call_snapshots: Vec = Vec::new(); + let mut text_thought_signature: Option = None; + let mut latest_usage: Option = None; + let mut latest_finish_reason: Option = None; + let mut blocked_text: Option = None; + tokio::pin!(stream); + + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes); + + while let Some(block) = take_sse_block(&mut buffer) { + if block.trim().is_empty() { + continue; + } + + let mut data_lines: Vec = Vec::new(); + for line in block.lines() { + if let Some(data) = strip_sse_field(line, "data") { + data_lines.push(data.to_string()); + } + } + + if data_lines.is_empty() { + continue; + } + + let data = data_lines.join("\n"); + if data.trim() == "[DONE]" { + break; + } + + let chunk_json: Value = match serde_json::from_str(&data) { + Ok(value) => value, + Err(_) => continue, + }; + + if message_id.is_none() { + message_id = chunk_json + .get("responseId") + .and_then(|value| value.as_str()) + .map(ToString::to_string); + } + if current_model.is_none() { + current_model = chunk_json + .get("modelVersion") + .and_then(|value| value.as_str()) + .map(ToString::to_string); + } + if latest_usage.is_none() { + latest_usage = chunk_json.get("usageMetadata").cloned(); + } + + if !has_sent_message_start { + let event = json!({ + "type": "message_start", + "message": { + "id": message_id.clone().unwrap_or_default(), + "type": "message", + "role": "assistant", + "model": current_model.clone().unwrap_or_default(), + "usage": build_anthropic_usage(chunk_json.get("usageMetadata")) + } + }); + yield Ok(encode_sse("message_start", &event)); + has_sent_message_start = true; + } + + if let Some(reason) = chunk_json + .get("promptFeedback") + .and_then(|value| value.get("blockReason")) + .and_then(|value| value.as_str()) + { + blocked_text = Some(format!("Request blocked by Gemini safety filters: {reason}")); + } + + if let Some(candidate) = chunk_json + .get("candidates") + .and_then(|value| value.as_array()) + .and_then(|value| value.first()) + { + if let Some(reason) = candidate.get("finishReason").and_then(|value| value.as_str()) { + latest_finish_reason = Some(reason.to_string()); + } + if let Some(usage) = chunk_json.get("usageMetadata") { + latest_usage = Some(usage.clone()); + } + if let Some(parts) = candidate + .get("content") + .and_then(|value| value.get("parts")) + .and_then(|value| value.as_array()) + { + let mut rectified_parts = parts.clone(); + rectify_tool_call_parts(&mut rectified_parts, tool_schema_hints.as_ref()); + if let Some(signature) = extract_text_thought_signature(parts) { + text_thought_signature = Some(signature); + } + merge_tool_call_snapshots( + &mut tool_call_snapshots, + extract_tool_calls(&rectified_parts, tool_schema_hints.as_ref()), + ); + let visible_text = extract_visible_text(&rectified_parts); + if !visible_text.is_empty() { + let is_cumulative = visible_text.starts_with(&accumulated_text); + let delta = if is_cumulative { + visible_text[accumulated_text.len()..].to_string() + } else { + visible_text.clone() + }; + + if !delta.is_empty() { + let index = *text_block_index.get_or_insert_with(|| { + let assigned = next_content_index; + next_content_index += 1; + assigned + }); + + if !open_indices.contains(&index) { + let start_event = json!({ + "type": "content_block_start", + "index": index, + "content_block": { + "type": "text", + "text": "" + } + }); + yield Ok(encode_sse("content_block_start", &start_event)); + open_indices.insert(index); + } + + let delta_event = json!({ + "type": "content_block_delta", + "index": index, + "delta": { + "type": "text_delta", + "text": delta + } + }); + yield Ok(encode_sse("content_block_delta", &delta_event)); + if is_cumulative { + accumulated_text = visible_text; + } else { + accumulated_text.push_str(&delta); + } + } + } + } + } + } + } + Err(error) => { + yield Err(std::io::Error::other(error.to_string())); + return; + } + } + } + + if !has_sent_message_start { + let event = json!({ + "type": "message_start", + "message": { + "id": message_id.clone().unwrap_or_default(), + "type": "message", + "role": "assistant", + "model": current_model.clone().unwrap_or_default(), + "usage": build_anthropic_usage(latest_usage.as_ref()) + } + }); + yield Ok(encode_sse("message_start", &event)); + } + + if accumulated_text.is_empty() { + if let Some(blocked_text) = blocked_text.clone() { + let index = *text_block_index.get_or_insert_with(|| { + let assigned = next_content_index; + next_content_index += 1; + assigned + }); + + if !open_indices.contains(&index) { + let start_event = json!({ + "type": "content_block_start", + "index": index, + "content_block": { + "type": "text", + "text": "" + } + }); + yield Ok(encode_sse("content_block_start", &start_event)); + open_indices.insert(index); + } + + let delta_event = json!({ + "type": "content_block_delta", + "index": index, + "delta": { + "type": "text_delta", + "text": blocked_text + } + }); + yield Ok(encode_sse("content_block_delta", &delta_event)); + } + } + + if let Some(index) = text_block_index { + if open_indices.remove(&index) { + let stop_event = json!({ + "type": "content_block_stop", + "index": index + }); + yield Ok(encode_sse("content_block_stop", &stop_event)); + } + } + + if let (Some(store), Some(provider_id), Some(session_id)) = ( + shadow_store.as_ref(), + provider_id.as_deref(), + session_id.as_deref(), + ) { + let tool_calls = tool_call_snapshots.clone(); + let shadow_text = if accumulated_text.is_empty() { + blocked_text.as_deref() + } else { + Some(accumulated_text.as_str()) + }; + let shadow_parts = build_shadow_assistant_parts( + shadow_text, + text_thought_signature.as_deref(), + &tool_calls, + ); + if !shadow_parts.is_empty() { + store.record_assistant_turn( + provider_id, + session_id, + json!({ "parts": shadow_parts }), + tool_calls.clone(), + ); + } + } + + // ------------------------------------------------------------------ + // Known trade-off: tool-call ordering vs. interleaved text. + // + // We emit all `tool_use` blocks *after* the final text + // `content_block_stop` above. If Gemini returns parts interleaved + // like `[text_a, functionCall_1, text_b, functionCall_2]`, the + // Anthropic-facing stream reorders them into `[text(a+b), + // tool_use_1, tool_use_2]`, whereas `gemini_to_anthropic_with_shadow_and_hints` + // (non-streaming) preserves the original part order. + // + // This is intentional given the current design: + // 1. Gemini `streamGenerateContent?alt=sse` delivers each chunk as + // a *cumulative* snapshot of `content.parts`. Emitting a + // `tool_use` content block on first observation would require + // closing the still-accumulating text block, then re-opening a + // new text block when more text arrives — producing many + // fragmented content blocks per message. + // 2. Anthropic clients we target (claude-code and similar) consume + // a message's tool calls by scanning for `tool_use` blocks and + // do not depend on strict text ↔ tool interleaving for + // correctness of tool execution or result routing. + // + // If a future client requires strict part-order fidelity in the + // streaming path, the fix is to track each part's original index, + // segment the accumulated text into multiple content blocks at + // tool-call boundaries, and flush in original order. + // ------------------------------------------------------------------ + let tool_calls = tool_call_snapshots; + for tool_call in &tool_calls { + let index = next_content_index; + next_content_index += 1; + + let start_event = json!({ + "type": "content_block_start", + "index": index, + "content_block": { + "type": "tool_use", + "id": tool_call.id.clone().unwrap_or_default(), + "name": tool_call.name + } + }); + yield Ok(encode_sse("content_block_start", &start_event)); + + let delta_event = json!({ + "type": "content_block_delta", + "index": index, + "delta": { + "type": "input_json_delta", + "partial_json": serde_json::to_string(&tool_call.args).unwrap_or_else(|_| "{}".to_string()) + } + }); + yield Ok(encode_sse("content_block_delta", &delta_event)); + + let stop_event = json!({ + "type": "content_block_stop", + "index": index + }); + yield Ok(encode_sse("content_block_stop", &stop_event)); + } + + let stop_reason = map_finish_reason( + latest_finish_reason.as_deref(), + !tool_calls.is_empty(), + blocked_text.is_some(), + ); + let usage = build_anthropic_usage(latest_usage.as_ref()); + let message_delta = json!({ + "type": "message_delta", + "delta": { + "stop_reason": stop_reason, + "stop_sequence": Value::Null + }, + "usage": usage + }); + yield Ok(encode_sse("message_delta", &message_delta)); + + let message_stop = json!({ "type": "message_stop" }); + yield Ok(encode_sse("message_stop", &message_stop)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proxy::providers::gemini_shadow::GeminiShadowStore; + use crate::proxy::providers::transform_gemini::anthropic_to_gemini_with_shadow; + use std::sync::Arc; + + fn collect_stream_output(chunks: Vec<&str>) -> String { + let owned_chunks: Vec = chunks.into_iter().map(ToString::to_string).collect(); + let stream = futures::stream::iter( + owned_chunks + .into_iter() + .map(|chunk| Ok::(Bytes::from(chunk))), + ); + let converted = create_anthropic_sse_stream_from_gemini(stream, None, None, None, None); + futures::executor::block_on(async move { + converted + .collect::>() + .await + .into_iter() + .map(|item| String::from_utf8(item.unwrap().to_vec()).unwrap()) + .collect::>() + .join("") + }) + } + + fn collect_stream_output_with_shadow( + chunks: Vec<&str>, + store: Arc, + provider_id: &str, + session_id: &str, + ) -> String { + let owned_chunks: Vec = chunks.into_iter().map(ToString::to_string).collect(); + let stream = futures::stream::iter( + owned_chunks + .into_iter() + .map(|chunk| Ok::(Bytes::from(chunk))), + ); + let converted = create_anthropic_sse_stream_from_gemini( + stream, + Some(store), + Some(provider_id.to_string()), + Some(session_id.to_string()), + None, + ); + futures::executor::block_on(async move { + converted + .collect::>() + .await + .into_iter() + .map(|item| String::from_utf8(item.unwrap().to_vec()).unwrap()) + .collect::>() + .join("") + }) + } + + #[test] + fn converts_text_stream_to_anthropic_sse() { + let output = collect_stream_output(vec![ + "data: {\"responseId\":\"resp_1\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hel\"}]}}],\"usageMetadata\":{\"promptTokenCount\":10,\"totalTokenCount\":13}}\n\n", + "data: {\"responseId\":\"resp_1\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"text\":\"Hello\"}]}}],\"usageMetadata\":{\"promptTokenCount\":10,\"totalTokenCount\":15}}\n\n", + ]); + + assert!(output.contains("event: message_start")); + assert!(output.contains("\"type\":\"text_delta\"")); + assert!(output.contains("\"text\":\"Hel\"")); + assert!(output.contains("\"text\":\"lo\"")); + assert!(output.contains("\"stop_reason\":\"end_turn\"")); + assert!(output.contains("event: message_stop")); + } + + #[test] + fn converts_function_call_stream_to_tool_use_events() { + let output = collect_stream_output(vec![ + "data: {\"responseId\":\"resp_2\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"functionCall\":{\"id\":\"call_1\",\"name\":\"get_weather\",\"args\":{\"city\":\"Tokyo\"}},\"thoughtSignature\":\"sig-1\"}]}}],\"usageMetadata\":{\"promptTokenCount\":5,\"totalTokenCount\":8}}\n\n", + ]); + + assert!(output.contains("\"type\":\"tool_use\"")); + assert!(output.contains("\"name\":\"get_weather\"")); + assert!(output.contains("\"type\":\"input_json_delta\"")); + assert!(output.contains("\"stop_reason\":\"tool_use\"")); + } + + #[test] + fn converts_crlf_delimited_stream_to_anthropic_sse() { + let output = collect_stream_output(vec![ + "data: {\"responseId\":\"resp_3\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hi\"}]}}],\"usageMetadata\":{\"promptTokenCount\":4,\"totalTokenCount\":6}}\r\n\r\n", + "data: {\"responseId\":\"resp_3\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"text\":\"Hi there\"}]}}],\"usageMetadata\":{\"promptTokenCount\":4,\"totalTokenCount\":9}}\r\n\r\n", + ]); + + assert!(output.contains("event: message_start")); + assert!(output.contains("\"type\":\"text_delta\"")); + assert!(output.contains("\"text\":\"Hi\"")); + assert!(output.contains("\"text\":\" there\"")); + assert!(output.contains("event: message_stop")); + } + + #[test] + fn preserves_utf8_boundaries_when_json_payload_spans_chunks() { + let payload = json!({ + "responseId": "resp_utf8", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ "text": "你好,Gemini" }] + } + }], + "usageMetadata": { + "promptTokenCount": 4, + "totalTokenCount": 8 + } + }); + let chunk = format!("data: {}\n\n", serde_json::to_string(&payload).unwrap()); + let split_at = chunk.find("你好").unwrap() + 1; + let chunk_bytes = chunk.into_bytes(); + let stream = futures::stream::iter([ + Ok::(Bytes::from(chunk_bytes[..split_at].to_vec())), + Ok::(Bytes::from(chunk_bytes[split_at..].to_vec())), + ]); + let converted = create_anthropic_sse_stream_from_gemini(stream, None, None, None, None); + let output = futures::executor::block_on(async move { + converted + .collect::>() + .await + .into_iter() + .map(|item| String::from_utf8(item.unwrap().to_vec()).unwrap()) + .collect::>() + .join("") + }); + + assert!(output.contains("你好,Gemini")); + assert!(!output.contains('\u{fffd}')); + } + + #[test] + fn stores_full_text_for_shadow_replay_across_delta_chunks() { + let store = Arc::new(GeminiShadowStore::with_limits(8, 4)); + let output = collect_stream_output_with_shadow( + vec![ + "data: {\"responseId\":\"resp_4\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Hel\"}]}}],\"usageMetadata\":{\"promptTokenCount\":4,\"totalTokenCount\":6}}\n\n", + "data: {\"responseId\":\"resp_4\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"text\":\"lo\"},{\"text\":\"\",\"thoughtSignature\":\"sig-1\"}]}}],\"usageMetadata\":{\"promptTokenCount\":4,\"totalTokenCount\":8}}\n\n", + ], + store.clone(), + "provider-a", + "session-1", + ); + + assert!(output.contains("\"text\":\"Hel\"")); + assert!(output.contains("\"text\":\"lo\"")); + + let shadow = store + .latest_assistant_content("provider-a", "session-1") + .unwrap(); + assert_eq!(shadow["parts"][0]["text"], "Hello"); + assert_eq!(shadow["parts"][0]["thoughtSignature"], "sig-1"); + + let second_turn = anthropic_to_gemini_with_shadow( + json!({ + "messages": [ + { "role": "user", "content": "Hi" }, + { "role": "assistant", "content": [{ "type": "text", "text": "Hello" }] }, + { "role": "user", "content": "Continue" } + ] + }), + Some(store.as_ref()), + Some("provider-a"), + Some("session-1"), + ) + .unwrap(); + + assert_eq!(second_turn["contents"][1]["role"], "model"); + assert_eq!(second_turn["contents"][1]["parts"][0]["text"], "Hello"); + assert_eq!( + second_turn["contents"][1]["parts"][0]["thoughtSignature"], + "sig-1" + ); + } + + #[test] + fn stores_tool_shadow_before_tool_use_events_are_fully_drained() { + let store = Arc::new(GeminiShadowStore::with_limits(8, 4)); + let chunks = vec![ + "data: {\"responseId\":\"resp_tool_shadow\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"functionCall\":{\"id\":\"call_1\",\"name\":\"Bash\",\"args\":{\"command\":\"ls -R\"}},\"thoughtSignature\":\"sig-tool-1\"}]}}],\"usageMetadata\":{\"promptTokenCount\":5,\"totalTokenCount\":8}}\n\n".to_string(), + ]; + let stream = futures::stream::iter( + chunks + .into_iter() + .map(|chunk| Ok::(Bytes::from(chunk))), + ); + let mut converted = Box::pin(create_anthropic_sse_stream_from_gemini( + stream, + Some(store.clone()), + Some("provider-a".to_string()), + Some("session-1".to_string()), + None, + )); + + futures::executor::block_on(async { + while let Some(item) = converted.next().await { + let event = String::from_utf8(item.unwrap().to_vec()).unwrap(); + if event.contains("\"type\":\"tool_use\"") { + break; + } + } + }); + + let shadow = store + .latest_assistant_content("provider-a", "session-1") + .unwrap(); + assert_eq!(shadow["parts"][0]["functionCall"]["name"], "Bash"); + assert_eq!(shadow["parts"][0]["thoughtSignature"], "sig-tool-1"); + } + + #[test] + fn rectifies_streamed_tool_call_args_from_tool_schema_hints() { + let owned_chunks = vec![ + "data: {\"responseId\":\"resp_5\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"functionCall\":{\"id\":\"call_1\",\"name\":\"Bash\",\"args\":{\"args\":\"git status\"}}}]}}],\"usageMetadata\":{\"promptTokenCount\":5,\"totalTokenCount\":8}}\n\n".to_string(), + ]; + let stream = futures::stream::iter( + owned_chunks + .into_iter() + .map(|chunk| Ok::(Bytes::from(chunk))), + ); + let hints = super::super::transform_gemini::extract_anthropic_tool_schema_hints(&json!({ + "tools": [{ + "name": "Bash", + "input_schema": { + "type": "object", + "properties": { + "command": { "type": "string" }, + "timeout": { "type": "number" } + }, + "required": ["command"] + } + }] + })); + let converted = + create_anthropic_sse_stream_from_gemini(stream, None, None, None, Some(hints)); + let output = futures::executor::block_on(async move { + converted + .collect::>() + .await + .into_iter() + .map(|item| String::from_utf8(item.unwrap().to_vec()).unwrap()) + .collect::>() + .join("") + }); + + assert!(output.contains("\"partial_json\":\"{\\\"command\\\":\\\"git status\\\"}\"")); + } + + #[test] + fn rectifies_streamed_skill_args_from_nested_parameters() { + let payload = json!({ + "responseId": "resp_6", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ + "functionCall": { + "id": "call_1", + "name": "Skill", + "args": { + "name": "git-commit", + "parameters": { + "args": ["详细分析内容 编写提交信息 分多次提交代码"] + } + } + } + }] + } + }], + "usageMetadata": { + "promptTokenCount": 5, + "totalTokenCount": 8 + } + }); + let owned_chunks = vec![format!( + "data: {}\n\n", + serde_json::to_string(&payload).unwrap() + )]; + let stream = futures::stream::iter( + owned_chunks + .into_iter() + .map(|chunk| Ok::(Bytes::from(chunk))), + ); + let hints = super::super::transform_gemini::extract_anthropic_tool_schema_hints(&json!({ + "tools": [{ + "name": "Skill", + "input_schema": { + "type": "object", + "properties": { + "skill": { "type": "string" }, + "args": { "type": "string" } + }, + "required": ["skill"] + } + }] + })); + let converted = + create_anthropic_sse_stream_from_gemini(stream, None, None, None, Some(hints)); + let output = futures::executor::block_on(async move { + converted + .collect::>() + .await + .into_iter() + .map(|item| String::from_utf8(item.unwrap().to_vec()).unwrap()) + .collect::>() + .join("") + }); + + assert!(output.contains("git-commit")); + assert!(output.contains("详细分析内容 编写提交信息 分多次提交代码")); + assert!(!output.contains("\\\"parameters\\\"")); + } + + /// Regression for the P1 finding: when Gemini emits two parallel calls to + /// the same function without providing ids, both must be surfaced to the + /// Anthropic client with distinct synthesized ids. The previous + /// name-based fallback in `merge_tool_call_snapshots` collapsed them into + /// a single entry, causing silent data loss for the first call. + #[test] + fn parallel_same_name_no_id_calls_preserve_both() { + let output = collect_stream_output(vec![ + "data: {\"responseId\":\"r1\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"Tokyo\"}}},{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"Osaka\"}}}]}}],\"usageMetadata\":{\"promptTokenCount\":5,\"totalTokenCount\":8}}\n\n", + ]); + + let tool_use_start_count = output.matches("\"type\":\"tool_use\"").count(); + assert_eq!( + tool_use_start_count, 2, + "both parallel calls must survive merge_tool_call_snapshots" + ); + // `input_json_delta.partial_json` is a string, so the city keys appear + // JSON-escaped inside the outer SSE `data:` payload. Match against + // the raw escape sequences rather than the canonical JSON form. + assert!(output.contains("Tokyo")); + assert!(output.contains("Osaka")); + // Each tool_use must carry a non-empty synthesized id so Claude Code + // can disambiguate the two tool_result round-trips. + let synth_count = output.matches("\"id\":\"gemini_synth_").count(); + assert_eq!(synth_count, 2); + } + + /// When Gemini keeps sending the same no-id functionCall across cumulative + /// chunks, the synthesized id must stay stable so the Anthropic client + /// sees a single tool_use block with consistent args updates rather than + /// duplicates. + #[test] + fn no_id_tool_call_reuses_synthesized_id_across_cumulative_chunks() { + let output = collect_stream_output(vec![ + "data: {\"responseId\":\"r2\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"Tokyo\"}}}]}}],\"usageMetadata\":{\"promptTokenCount\":4,\"totalTokenCount\":6}}\n\n", + "data: {\"responseId\":\"r2\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"Tokyo\",\"units\":\"c\"}}}]}}],\"usageMetadata\":{\"promptTokenCount\":4,\"totalTokenCount\":9}}\n\n", + ]); + + assert_eq!(output.matches("\"type\":\"tool_use\"").count(), 1); + assert!(output.contains("\"units\\\":\\\"c\\\"")); + } + + /// Regression for the follow-up Codex P1: some Gemini relays serialize + /// an absent functionCall id as `"id": ""` rather than omitting the + /// field. Without a filter, `Some("")` would reach + /// `merge_tool_call_snapshots`, two parallel no-id calls would match + /// each other on the empty-string id, and the second would overwrite + /// the first — silently losing a call. Also the emitted Anthropic + /// `tool_use.id` would be the empty string, so tool_result + /// correlation from the Claude client would break. + #[test] + fn parallel_empty_string_id_calls_are_treated_as_missing_and_preserved() { + let output = collect_stream_output(vec![ + "data: {\"responseId\":\"r3\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"functionCall\":{\"id\":\"\",\"name\":\"get_weather\",\"args\":{\"city\":\"Tokyo\"}}},{\"functionCall\":{\"id\":\"\",\"name\":\"get_weather\",\"args\":{\"city\":\"Osaka\"}}}]}}],\"usageMetadata\":{\"promptTokenCount\":5,\"totalTokenCount\":8}}\n\n", + ]); + + let tool_use_count = output.matches("\"type\":\"tool_use\"").count(); + assert_eq!( + tool_use_count, 2, + "both parallel calls must survive even when ids are explicit empty strings" + ); + assert!(output.contains("Tokyo")); + assert!(output.contains("Osaka")); + // No tool_use may emit an empty id — each must get its own + // synthesized id so tool_result correlation works. + assert!( + !output.contains("\"id\":\"\""), + "empty tool_use id leaked through: {output}" + ); + let synth_count = output.matches("\"id\":\"gemini_synth_").count(); + assert_eq!(synth_count, 2); + } + + /// Companion regression: a single-chunk stream whose sole functionCall + /// carries `"id": ""` must still emit exactly one tool_use with a + /// synthesized id, not an empty one. This covers the non-parallel + /// degraded-relay case that the parallel test above subsumes. + #[test] + fn single_empty_string_id_tool_call_gets_synthesized_id() { + let output = collect_stream_output(vec![ + "data: {\"responseId\":\"r4\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"functionCall\":{\"id\":\"\",\"name\":\"get_weather\",\"args\":{\"city\":\"Tokyo\"}}}]}}],\"usageMetadata\":{\"promptTokenCount\":3,\"totalTokenCount\":5}}\n\n", + ]); + + assert_eq!(output.matches("\"type\":\"tool_use\"").count(), 1); + assert!(!output.contains("\"id\":\"\"")); + assert_eq!(output.matches("\"id\":\"gemini_synth_").count(), 1); + } + + /// Regression for Codex P1: Gemini's cumulative stream may deliver a + /// `functionCall` without an id (we synthesize one) and then upgrade + /// to a genuine id on a later chunk. Without a positional fallback in + /// the `Some(incoming_id)` branch of `merge_tool_call_snapshots`, the + /// real id would fail to match the existing synthesized snapshot and + /// push a second entry — yielding duplicate `tool_use` blocks at + /// stream end (one synthesized, one real) and breaking tool_result + /// correlation. + #[test] + fn upgraded_real_id_merges_into_existing_synthesized_snapshot() { + let output = collect_stream_output(vec![ + // Chunk 1: no id -> a `gemini_synth_*` id is assigned. + "data: {\"responseId\":\"rupg\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"get_weather\",\"args\":{\"city\":\"Tokyo\"}}}]}}],\"usageMetadata\":{\"promptTokenCount\":4,\"totalTokenCount\":6}}\n\n", + // Chunk 2: cumulative snapshot upgrades the same call to a + // real Gemini id. Must merge into the existing slot, not + // spawn a second snapshot. + "data: {\"responseId\":\"rupg\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"functionCall\":{\"id\":\"real_id_abc\",\"name\":\"get_weather\",\"args\":{\"city\":\"Tokyo\",\"units\":\"c\"}}}]}}],\"usageMetadata\":{\"promptTokenCount\":4,\"totalTokenCount\":9}}\n\n", + ]); + + // Exactly one tool_use block (not two). + assert_eq!( + output.matches("\"type\":\"tool_use\"").count(), + 1, + "id upgrade must merge into the synthesized snapshot, not duplicate it: {output}" + ); + // The emitted tool_use id is the real Gemini id, not the synthesized one. + assert!( + output.contains("\"id\":\"real_id_abc\""), + "expected real id to win after upgrade: {output}" + ); + assert!( + !output.contains("\"id\":\"gemini_synth_"), + "synthesized id must be dropped when a real id arrives: {output}" + ); + // Args from the final cumulative snapshot are emitted. + assert!(output.contains("units")); + } + + /// Regression for Codex P2: Gemini's cumulative stream may include + /// `thoughtSignature` on one chunk and omit it on a later cumulative + /// snapshot of the same call. A blind `tool_call_snapshots[index] = + /// tool_call` overwrite would drop the signature, so the shadow turn + /// recorded (and later replayed to Gemini) would miss + /// `thoughtSignature` and the upstream would reject the follow-up. + /// `merge_tool_call_snapshots` must retain the prior signature when + /// the incoming chunk does not carry one. + #[test] + fn thought_signature_preserved_when_later_chunk_omits_it() { + let store = Arc::new(GeminiShadowStore::with_limits(8, 4)); + collect_stream_output_with_shadow( + vec![ + // Chunk 1: carries thoughtSignature "sig-keep". + "data: {\"responseId\":\"rsig\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"id\":\"call_1\",\"name\":\"get_weather\",\"args\":{\"city\":\"Tokyo\"}},\"thoughtSignature\":\"sig-keep\"}]}}],\"usageMetadata\":{\"promptTokenCount\":4,\"totalTokenCount\":6}}\n\n", + // Chunk 2: cumulative update for the same call, but + // thoughtSignature is omitted — common for Gemini's + // one-shot signature fields. + "data: {\"responseId\":\"rsig\",\"modelVersion\":\"gemini-2.5-pro\",\"candidates\":[{\"finishReason\":\"STOP\",\"content\":{\"parts\":[{\"functionCall\":{\"id\":\"call_1\",\"name\":\"get_weather\",\"args\":{\"city\":\"Tokyo\",\"units\":\"c\"}}}]}}],\"usageMetadata\":{\"promptTokenCount\":4,\"totalTokenCount\":9}}\n\n", + ], + store.clone(), + "provider-sig", + "session-sig", + ); + + let shadow = store + .latest_assistant_content("provider-sig", "session-sig") + .expect("shadow turn must be recorded"); + assert_eq!(shadow["parts"][0]["functionCall"]["id"], "call_1"); + assert_eq!( + shadow["parts"][0]["thoughtSignature"], "sig-keep", + "prior thoughtSignature must survive a later chunk that omits it: {shadow}" + ); + } +} diff --git a/src-tauri/src/proxy/providers/streaming_responses.rs b/src-tauri/src/proxy/providers/streaming_responses.rs index 2c4b306e2..1eba5033e 100644 --- a/src-tauri/src/proxy/providers/streaming_responses.rs +++ b/src-tauri/src/proxy/providers/streaming_responses.rs @@ -9,7 +9,7 @@ //! 与 Chat Completions 的 delta chunk 模型完全不同,需要独立的状态机处理。 use super::transform_responses::{build_anthropic_usage_from_responses, map_responses_stop_reason}; -use crate::proxy::sse::strip_sse_field; +use crate::proxy::sse::{strip_sse_field, take_sse_block}; use bytes::Bytes; use futures::stream::{Stream, StreamExt}; use serde_json::{json, Value}; @@ -122,10 +122,7 @@ pub fn create_anthropic_sse_stream_from_responses, + required_keys: Vec, +} + +pub type AnthropicToolSchemaHints = HashMap; + +/// Prefix used for Anthropic-visible tool call ids that we synthesize when +/// Gemini's `functionCall` omits the `id` field (Gemini 2.x parallel calls +/// often do). The prefix is how downstream request-path code recognizes that +/// the id is not a real Gemini id and must be stripped before forwarding back +/// to Gemini as `functionResponse.id`. +pub(crate) const SYNTHESIZED_ID_PREFIX: &str = "gemini_synth_"; + +/// Generate a unique tool-call id that is safe to expose to Anthropic clients +/// but must not be sent upstream to Gemini. Uses UUID v4 simple encoding +/// (32 lowercase hex chars) so that any number of parallel calls in the same +/// response remain distinguishable. +pub(crate) fn synthesize_tool_call_id() -> String { + format!("{SYNTHESIZED_ID_PREFIX}{}", uuid::Uuid::new_v4().simple()) +} + +/// Returns true if `id` was produced by [`synthesize_tool_call_id`] and +/// therefore must be stripped when building Gemini request bodies. +pub(crate) fn is_synthesized_tool_call_id(id: &str) -> bool { + id.starts_with(SYNTHESIZED_ID_PREFIX) +} + +pub fn anthropic_to_gemini(body: Value) -> Result { + anthropic_to_gemini_with_shadow(body, None, None, None) +} + +pub fn anthropic_to_gemini_with_shadow( + body: Value, + shadow_store: Option<&GeminiShadowStore>, + provider_id: Option<&str>, + session_id: Option<&str>, +) -> Result { + let mut result = json!({}); + let shadow_turns = shadow_store + .zip(provider_id) + .zip(session_id) + .and_then(|((store, provider_id), session_id)| store.get_session(provider_id, session_id)) + .map(|snapshot| snapshot.turns) + .unwrap_or_default(); + + if let Some(system) = build_system_instruction(body.get("system"))? { + result["systemInstruction"] = system; + } + + if let Some(messages) = body.get("messages").and_then(|value| value.as_array()) { + result["contents"] = json!(convert_messages_to_contents(messages, &shadow_turns)?); + } + + if let Some(generation_config) = build_generation_config(&body) { + result["generationConfig"] = generation_config; + } + + if let Some(tools) = body.get("tools").and_then(|value| value.as_array()) { + let function_declarations: Vec = tools + .iter() + .filter(|tool| tool.get("type").and_then(|value| value.as_str()) != Some("BatchTool")) + .map(|tool| { + build_gemini_function_declaration( + tool.get("name") + .and_then(|value| value.as_str()) + .unwrap_or(""), + tool.get("description").and_then(|value| value.as_str()), + tool.get("input_schema") + .cloned() + .unwrap_or_else(|| json!({})), + ) + }) + .collect(); + + if !function_declarations.is_empty() { + result["tools"] = json!([{ "functionDeclarations": function_declarations }]); + } + } + + if let Some(tool_config) = map_tool_choice(body.get("tool_choice"))? { + result["toolConfig"] = tool_config; + } + + Ok(result) +} + +/// Convenience wrapper over [`gemini_to_anthropic_with_shadow_and_hints`] +/// with no shadow store or schema hints. Used by the shared +/// `ProviderAdapter::transform_response` path and by tests. +#[allow(dead_code)] // kept as public API for non-streaming transform paths +pub fn gemini_to_anthropic(body: Value) -> Result { + gemini_to_anthropic_with_shadow(body, None, None, None) +} + +/// Convenience wrapper for callers that have a shadow store but no tool +/// schema hints. Production call sites funnel through +/// [`gemini_to_anthropic_with_shadow_and_hints`] directly; this helper exists +/// for test ergonomics and future external callers. +#[allow(dead_code)] // kept as public API for shadow-only transform paths +pub fn gemini_to_anthropic_with_shadow( + body: Value, + shadow_store: Option<&GeminiShadowStore>, + provider_id: Option<&str>, + session_id: Option<&str>, +) -> Result { + gemini_to_anthropic_with_shadow_and_hints(body, shadow_store, provider_id, session_id, None) +} + +pub fn gemini_to_anthropic_with_shadow_and_hints( + body: Value, + shadow_store: Option<&GeminiShadowStore>, + provider_id: Option<&str>, + session_id: Option<&str>, + tool_schema_hints: Option<&AnthropicToolSchemaHints>, +) -> Result { + if let Some(block_reason) = body + .get("promptFeedback") + .and_then(|value| value.get("blockReason")) + .and_then(|value| value.as_str()) + { + let text = format!("Request blocked by Gemini safety filters: {block_reason}"); + return Ok(json!({ + "id": body.get("responseId").and_then(|value| value.as_str()).unwrap_or(""), + "type": "message", + "role": "assistant", + "content": [{ "type": "text", "text": text }], + "model": body.get("modelVersion").and_then(|value| value.as_str()).unwrap_or(""), + "stop_reason": "refusal", + "stop_sequence": Value::Null, + "usage": build_anthropic_usage(body.get("usageMetadata")) + })); + } + + let candidate = body + .get("candidates") + .and_then(|value| value.as_array()) + .and_then(|value| value.first()) + .ok_or_else(|| { + ProxyError::TransformError("No candidates in Gemini response".to_string()) + })?; + + let parts = candidate + .get("content") + .and_then(|value| value.get("parts")) + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(); + + let mut rectified_parts = parts.clone(); + rectify_tool_call_parts(&mut rectified_parts, tool_schema_hints); + + // Pre-pass: for every `functionCall` that lacks an id (or carries an + // empty-string id), synthesize one and write it back into + // `rectified_parts`. Three independent readers — the + // Anthropic-visible `content[tool_use]` block below, the shadow + // store's `assistant_content` (cloned from `rectified_parts` further + // down), and `extract_tool_call_meta(&rectified_parts)` that populates + // `shadow_turn.tool_calls` — must all see the same id. Otherwise the + // client would receive id A while the shadow stored id B, and the + // next round's `tool_result(tool_use_id=A)` would fail to resolve + // through `tool_name_by_id` (which is built from the shadow), raising + // `Unable to resolve Gemini functionResponse.name`. Streaming path + // already has this single-source-of-truth property via + // `tool_call_snapshots`. + for part in rectified_parts.iter_mut() { + let Some(function_call) = part.get_mut("functionCall").and_then(|v| v.as_object_mut()) + else { + continue; + }; + let needs_synth = function_call + .get("id") + .and_then(|v| v.as_str()) + .map(|s| s.is_empty()) + .unwrap_or(true); + if needs_synth { + function_call.insert("id".to_string(), json!(synthesize_tool_call_id())); + } + } + + let mut content = Vec::new(); + let mut has_tool_use = false; + + for part in &rectified_parts { + if part.get("thought").and_then(|value| value.as_bool()) == Some(true) { + continue; + } + + if let Some(text) = part.get("text").and_then(|value| value.as_str()) { + if !text.is_empty() { + content.push(json!({ + "type": "text", + "text": text + })); + } + continue; + } + + if let Some(function_call) = part.get("functionCall") { + has_tool_use = true; + let id = function_call + .get("id") + .and_then(|value| value.as_str()) + .filter(|s| !s.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(synthesize_tool_call_id); + content.push(json!({ + "type": "tool_use", + "id": id, + "name": function_call.get("name").and_then(|value| value.as_str()).unwrap_or(""), + "input": function_call.get("args").cloned().unwrap_or_else(|| json!({})) + })); + } + } + + let stop_reason = map_finish_reason( + candidate + .get("finishReason") + .and_then(|value| value.as_str()), + has_tool_use, + ); + + let anthropic_response = json!({ + "id": body.get("responseId").and_then(|value| value.as_str()).unwrap_or(""), + "type": "message", + "role": "assistant", + "content": content, + "model": body.get("modelVersion").and_then(|value| value.as_str()).unwrap_or(""), + "stop_reason": stop_reason, + "stop_sequence": Value::Null, + "usage": build_anthropic_usage(body.get("usageMetadata")) + }); + + if let (Some(store), Some(provider_id), Some(session_id), Some(content)) = ( + shadow_store, + provider_id, + session_id, + candidate.get("content"), + ) { + let mut shadow_content = content.clone(); + if let Some(parts_value) = shadow_content.get_mut("parts") { + *parts_value = json!(rectified_parts.clone()); + } + store.record_assistant_turn( + provider_id, + session_id, + shadow_content, + extract_tool_call_meta(&rectified_parts), + ); + } + + Ok(anthropic_response) +} + +pub fn extract_gemini_model(body: &Value) -> Option<&str> { + body.get("model").and_then(|value| value.as_str()) +} + +fn build_system_instruction(system: Option<&Value>) -> Result, ProxyError> { + let Some(system) = system else { + return Ok(None); + }; + + if let Some(text) = system.as_str() { + if text.is_empty() { + return Ok(None); + } + return Ok(Some(json!({ + "parts": [{ "text": text }] + }))); + } + + let Some(blocks) = system.as_array() else { + return Err(ProxyError::TransformError( + "Anthropic system must be a string or an array".to_string(), + )); + }; + + let texts: Vec<&str> = blocks + .iter() + .filter_map(|block| block.get("text").and_then(|value| value.as_str())) + .filter(|text| !text.is_empty()) + .collect(); + + if texts.is_empty() { + return Ok(None); + } + + Ok(Some(json!({ + "parts": [{ "text": texts.join("\n\n") }] + }))) +} + +fn build_generation_config(body: &Value) -> Option { + let mut config = Map::new(); + + if let Some(value) = body.get("max_tokens") { + config.insert("maxOutputTokens".to_string(), value.clone()); + } + if let Some(value) = body.get("temperature") { + config.insert("temperature".to_string(), value.clone()); + } + if let Some(value) = body.get("top_p") { + config.insert("topP".to_string(), value.clone()); + } + if let Some(value) = body.get("stop_sequences") { + config.insert("stopSequences".to_string(), value.clone()); + } + + if config.is_empty() { + None + } else { + Some(Value::Object(config)) + } +} + +fn convert_messages_to_contents( + messages: &[Value], + shadow_turns: &[GeminiAssistantTurn], +) -> Result, ProxyError> { + let mut contents = Vec::new(); + let mut used_shadow_indices = HashSet::new(); + let total_assistant_messages = messages + .iter() + .filter(|message| message.get("role").and_then(|value| value.as_str()) == Some("assistant")) + .count(); + let effective_shadow_turns = if shadow_turns.len() > total_assistant_messages { + &shadow_turns[shadow_turns.len() - total_assistant_messages..] + } else { + shadow_turns + }; + let mut tool_name_by_id = build_tool_name_map_from_shadow_turns(shadow_turns); + let shadow_start_index = total_assistant_messages.saturating_sub(effective_shadow_turns.len()); + let mut assistant_seen_index = 0usize; + + for message in messages { + let role = message + .get("role") + .and_then(|value| value.as_str()) + .unwrap_or("user"); + + let gemini_role = if role == "assistant" { "model" } else { "user" }; + + let parts = if role == "assistant" { + let positional_shadow_index = assistant_seen_index + .checked_sub(shadow_start_index) + .filter(|index| *index < effective_shadow_turns.len()) + .filter(|index| !used_shadow_indices.contains(index)); + let tool_use_match_index = find_matching_shadow_turn_for_assistant_message( + message.get("content"), + effective_shadow_turns, + ) + .filter(|index| !used_shadow_indices.contains(index)); + assistant_seen_index += 1; + let shadow_index = tool_use_match_index.or(positional_shadow_index); + + if let Some(index) = shadow_index { + used_shadow_indices.insert(index); + let shadow_turn = &effective_shadow_turns[index]; + merge_tool_names_from_shadow(shadow_turn, &mut tool_name_by_id); + if let Some(parts) = shadow_parts(&shadow_turn.assistant_content) { + parts + } else { + convert_message_content_to_parts( + message.get("content"), + role, + &mut tool_name_by_id, + )? + } + } else { + convert_message_content_to_parts( + message.get("content"), + role, + &mut tool_name_by_id, + )? + } + } else { + convert_message_content_to_parts(message.get("content"), role, &mut tool_name_by_id)? + }; + + if role == "assistant" { + merge_tool_names_from_parts(&parts, &mut tool_name_by_id); + } + + contents.push(json!({ + "role": gemini_role, + "parts": parts + })); + } + + Ok(contents) +} + +fn find_matching_shadow_turn_for_assistant_message( + content: Option<&Value>, + shadow_turns: &[GeminiAssistantTurn], +) -> Option { + let (tool_use_ids, tool_use_names) = extract_assistant_tool_use_keys(content); + if tool_use_ids.is_empty() && tool_use_names.is_empty() { + return None; + } + + // Prefer exact tool-call id match. With identical tool suffixes across + // servers (e.g. `server_a:search` and `server_b:search`) the + // normalized-name clause below would otherwise match an earlier shadow + // turn whose id is actually wrong for this message, mis-routing replay + // state (functionCall id / thoughtSignature) for later tool_result + // resolution. Only fall back to name matching when id-based lookup fails + // or when the incoming message carries no ids at all. + if !tool_use_ids.is_empty() { + if let Some(index) = shadow_turns.iter().position(|turn| { + turn.tool_calls.iter().any(|tool_call| { + tool_call + .id + .as_deref() + .is_some_and(|id| tool_use_ids.contains(id)) + }) + }) { + return Some(index); + } + } + + shadow_turns.iter().enumerate().find_map(|(index, turn)| { + turn.tool_calls + .iter() + .any(|tool_call| { + tool_use_names.contains(tool_call.name.as_str()) + || tool_use_names.contains(normalize_tool_name(&tool_call.name)) + }) + .then_some(index) + }) +} + +fn extract_assistant_tool_use_keys(content: Option<&Value>) -> (HashSet, HashSet) { + let mut tool_use_ids = HashSet::new(); + let mut tool_use_names = HashSet::new(); + let Some(blocks) = content.and_then(|value| value.as_array()) else { + return (tool_use_ids, tool_use_names); + }; + + for block in blocks { + if block.get("type").and_then(|value| value.as_str()) != Some("tool_use") { + continue; + } + + if let Some(id) = block + .get("id") + .and_then(|value| value.as_str()) + .filter(|id| !id.is_empty()) + { + tool_use_ids.insert(id.to_string()); + } + + if let Some(name) = block + .get("name") + .and_then(|value| value.as_str()) + .filter(|name| !name.is_empty()) + { + tool_use_names.insert(name.to_string()); + tool_use_names.insert(normalize_tool_name(name).to_string()); + } + } + + (tool_use_ids, tool_use_names) +} + +fn normalize_tool_name(name: &str) -> &str { + name.rsplit(':').next().unwrap_or(name) +} + +fn convert_message_content_to_parts( + content: Option<&Value>, + role: &str, + tool_name_by_id: &mut std::collections::HashMap, +) -> Result, ProxyError> { + let Some(content) = content else { + return Ok(Vec::new()); + }; + + if let Some(text) = content.as_str() { + return Ok(vec![json!({ "text": text })]); + } + + let Some(blocks) = content.as_array() else { + return Err(ProxyError::TransformError( + "Anthropic message content must be a string or array".to_string(), + )); + }; + + let mut parts = Vec::new(); + + for block in blocks { + let block_type = block + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or(""); + + match block_type { + "text" => { + if let Some(text) = block.get("text").and_then(|value| value.as_str()) { + parts.push(json!({ "text": text })); + } + } + "image" => { + let source = block.get("source").ok_or_else(|| { + ProxyError::TransformError("Gemini image block missing source".to_string()) + })?; + + let source_type = source + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or(""); + + if source_type != "base64" { + return Err(ProxyError::TransformError(format!( + "Gemini Native only supports base64 image sources, got `{source_type}`" + ))); + } + + parts.push(json!({ + "inlineData": { + "mimeType": source.get("media_type").and_then(|value| value.as_str()).unwrap_or("image/png"), + "data": source.get("data").and_then(|value| value.as_str()).unwrap_or("") + } + })); + } + "document" => { + let source = block.get("source").ok_or_else(|| { + ProxyError::TransformError("Gemini document block missing source".to_string()) + })?; + + let source_type = source + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or(""); + + if source_type != "base64" { + return Err(ProxyError::TransformError(format!( + "Gemini Native only supports base64 document sources, got `{source_type}`" + ))); + } + + parts.push(json!({ + "inlineData": { + "mimeType": source.get("media_type").and_then(|value| value.as_str()).unwrap_or("application/pdf"), + "data": source.get("data").and_then(|value| value.as_str()).unwrap_or("") + } + })); + } + "tool_use" => { + if role != "assistant" { + return Err(ProxyError::TransformError( + "tool_use blocks are only valid in assistant messages".to_string(), + )); + } + + let id = block + .get("id") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let name = block + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or(""); + if !id.is_empty() && !name.is_empty() { + tool_name_by_id.insert(id.to_string(), name.to_string()); + } + + // A synthesized id is an internal proxy identifier — never + // forward it to Gemini. Gemini will disambiguate the missing + // id by call order, matching its own earlier response shape. + let mut function_call = json!({ + "name": name, + "args": block.get("input").cloned().unwrap_or_else(|| json!({})) + }); + if !id.is_empty() && !is_synthesized_tool_call_id(id) { + function_call["id"] = json!(id); + } + + parts.push(json!({ "functionCall": function_call })); + } + "tool_result" => { + let tool_use_id = block + .get("tool_use_id") + .and_then(|value| value.as_str()) + .unwrap_or(""); + let name = tool_name_by_id + .get(tool_use_id) + .cloned() + .ok_or_else(|| { + ProxyError::TransformError(format!( + "Unable to resolve Gemini functionResponse.name for tool_use_id `{tool_use_id}`" + )) + })?; + + // See `tool_use` above: synthesized ids must not leak upstream. + let mut function_response = json!({ + "name": name, + "response": normalize_tool_result_response(block.get("content")) + }); + if !tool_use_id.is_empty() && !is_synthesized_tool_call_id(tool_use_id) { + function_response["id"] = json!(tool_use_id); + } + + parts.push(json!({ "functionResponse": function_response })); + } + "thinking" | "redacted_thinking" => {} + _ => {} + } + } + + Ok(parts) +} + +fn normalize_tool_result_response(content: Option<&Value>) -> Value { + match content { + Some(Value::String(text)) => json!({ "content": text }), + Some(Value::Array(blocks)) => { + let texts: Vec<&str> = blocks + .iter() + .filter(|block| block.get("type").and_then(|value| value.as_str()) == Some("text")) + .filter_map(|block| block.get("text").and_then(|value| value.as_str())) + .collect(); + + if texts.is_empty() { + json!({ "content": Value::Array(blocks.clone()) }) + } else { + json!({ "content": texts.join("\n") }) + } + } + Some(value) => json!({ "content": value.clone() }), + None => json!({ "content": "" }), + } +} + +fn shadow_parts(content: &Value) -> Option> { + let mut parts = content + .get("parts") + .and_then(|value| value.as_array()) + .cloned() + .or_else(|| content.as_array().cloned())?; + // Strip synthesized ids before these parts are replayed into a Gemini + // request body. The shadow store records the Anthropic-facing id so that + // a tool_result round-trip can find the tool's name, but sending the + // synthetic value as `functionCall.id` upstream would leak an internal + // identifier. + for part in &mut parts { + let Some(function_call) = part.get_mut("functionCall").and_then(|v| v.as_object_mut()) + else { + continue; + }; + let drop_id = function_call + .get("id") + .and_then(|v| v.as_str()) + .map(|id| id.is_empty() || is_synthesized_tool_call_id(id)) + .unwrap_or(true); + if drop_id { + function_call.remove("id"); + } + } + Some(parts) +} + +pub fn extract_anthropic_tool_schema_hints(body: &Value) -> AnthropicToolSchemaHints { + body.get("tools") + .and_then(|value| value.as_array()) + .into_iter() + .flatten() + .filter_map(|tool| { + let name = tool.get("name").and_then(|value| value.as_str())?; + let input_schema = tool + .get("input_schema") + .and_then(|value| value.as_object())?; + let properties = input_schema + .get("properties") + .and_then(|value| value.as_object())?; + if properties.is_empty() { + return None; + } + + let expected_keys = properties.keys().cloned().collect::>(); + let required_keys = input_schema + .get("required") + .and_then(|value| value.as_array()) + .map(|values| { + values + .iter() + .filter_map(|value| value.as_str().map(ToString::to_string)) + .collect::>() + }) + .unwrap_or_default(); + + Some(( + name.to_string(), + AnthropicToolSchemaHint { + expected_keys, + required_keys, + }, + )) + }) + .collect() +} + +pub fn rectify_tool_call_parts( + parts: &mut [Value], + tool_schema_hints: Option<&AnthropicToolSchemaHints>, +) { + for part in parts { + let Some(function_call) = part + .get_mut("functionCall") + .and_then(|value| value.as_object_mut()) + else { + continue; + }; + let Some(name) = function_call + .get("name") + .and_then(|value| value.as_str()) + .map(ToString::to_string) + else { + continue; + }; + let Some(args) = function_call.get_mut("args") else { + continue; + }; + + if rectify_tool_call_args(&name, args, tool_schema_hints) { + log::info!("[Claude/Gemini] Rectified tool args for `{name}`"); + } + } +} + +pub fn rectify_tool_call_args( + tool_name: &str, + args: &mut Value, + tool_schema_hints: Option<&AnthropicToolSchemaHints>, +) -> bool { + let Some(tool_schema_hints) = tool_schema_hints else { + return false; + }; + let Some(hint) = tool_schema_hints.get(tool_name) else { + return false; + }; + let Some(args_object) = args.as_object_mut() else { + return false; + }; + if args_object.is_empty() || hint.expected_keys.is_empty() { + return false; + } + let mut changed = false; + + if hint.expected_keys.iter().any(|key| key == "skill") && !args_object.contains_key("skill") { + if let Some(value) = args_object.remove("name") { + args_object.insert("skill".to_string(), value); + changed = true; + } + } + + let expects_parameters_key = hint.expected_keys.iter().any(|key| key == "parameters"); + if !expects_parameters_key { + let extracted_parameters = args_object + .get("parameters") + .and_then(|value| value.as_object()) + .map(|parameters_object| { + hint.expected_keys + .iter() + .filter_map(|expected_key| { + if args_object.contains_key(expected_key) { + return None; + } + let value = parameters_object.get(expected_key)?; + let normalized_value = match value { + Value::Array(values) if values.len() == 1 => values[0].clone(), + _ => value.clone(), + }; + Some((expected_key.clone(), normalized_value)) + }) + .collect::>() + }) + .unwrap_or_default(); + + if !extracted_parameters.is_empty() { + for (expected_key, normalized_value) in extracted_parameters { + args_object.insert(expected_key, normalized_value); + } + args_object.remove("parameters"); + changed = true; + } + } + + if hint + .required_keys + .iter() + .all(|key| args_object.contains_key(key.as_str())) + { + return changed; + } + + let expected_key_set = hint + .expected_keys + .iter() + .map(String::as_str) + .collect::>(); + let unexpected_keys = args_object + .keys() + .filter(|key| !expected_key_set.contains(key.as_str())) + .cloned() + .collect::>(); + if unexpected_keys.len() != 1 { + return false; + } + + let target_key = hint + .required_keys + .iter() + .find(|key| !args_object.contains_key(key.as_str())) + .cloned() + .or_else(|| { + if hint.expected_keys.len() == 1 && args_object.len() == 1 { + hint.expected_keys.first().cloned() + } else { + None + } + }); + let Some(target_key) = target_key else { + return false; + }; + if args_object.contains_key(&target_key) { + return false; + } + + let source_key = &unexpected_keys[0]; + let Some(value) = args_object.remove(source_key) else { + return false; + }; + args_object.insert(target_key, value); + true +} + +fn merge_tool_names_from_shadow( + turn: &GeminiAssistantTurn, + tool_name_by_id: &mut HashMap, +) { + for tool_call in &turn.tool_calls { + if let Some(id) = &tool_call.id { + tool_name_by_id.insert(id.clone(), tool_call.name.clone()); + } + } + + if let Some(parts) = shadow_parts(&turn.assistant_content) { + merge_tool_names_from_parts(&parts, tool_name_by_id); + } +} + +fn build_tool_name_map_from_shadow_turns( + shadow_turns: &[GeminiAssistantTurn], +) -> HashMap { + let mut tool_name_by_id = HashMap::new(); + for turn in shadow_turns { + merge_tool_names_from_shadow(turn, &mut tool_name_by_id); + } + tool_name_by_id +} + +fn merge_tool_names_from_parts(parts: &[Value], tool_name_by_id: &mut HashMap) { + for part in parts { + let Some(function_call) = part.get("functionCall") else { + continue; + }; + let Some(id) = function_call.get("id").and_then(|value| value.as_str()) else { + continue; + }; + let Some(name) = function_call.get("name").and_then(|value| value.as_str()) else { + continue; + }; + if !id.is_empty() && !name.is_empty() { + tool_name_by_id.insert(id.to_string(), name.to_string()); + } + } +} + +fn extract_tool_call_meta(parts: &[Value]) -> Vec { + parts + .iter() + .filter_map(|part| { + let function_call = part.get("functionCall")?; + // Ensure every surfaced tool call carries a distinguishing id. + // Gemini 2.x may omit `id` on parallel calls; synthesizing a + // unique replacement prevents downstream merge/replay logic from + // collapsing distinct calls onto a single empty-string key. + let id = function_call + .get("id") + .and_then(|value| value.as_str()) + .filter(|s| !s.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(synthesize_tool_call_id); + Some(GeminiToolCallMeta::new( + Some(id), + function_call + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or(""), + function_call + .get("args") + .cloned() + .unwrap_or_else(|| json!({})), + part.get("thoughtSignature") + .or_else(|| part.get("thought_signature")) + .and_then(|value| value.as_str()), + )) + }) + .collect() +} + +fn map_tool_choice(tool_choice: Option<&Value>) -> Result, ProxyError> { + let Some(tool_choice) = tool_choice else { + return Ok(None); + }; + + match tool_choice { + Value::String(choice) => Ok(match choice.as_str() { + "auto" => Some(json!({ + "functionCallingConfig": { "mode": "AUTO" } + })), + "none" => Some(json!({ + "functionCallingConfig": { "mode": "NONE" } + })), + other => { + return Err(ProxyError::TransformError(format!( + "Unsupported Gemini tool_choice string: {other}" + ))); + } + }), + Value::Object(object) => { + let Some(choice_type) = object.get("type").and_then(|value| value.as_str()) else { + return Ok(None); + }; + + let config = match choice_type { + "auto" => json!({ "mode": "AUTO" }), + "none" => json!({ "mode": "NONE" }), + "any" => json!({ "mode": "ANY" }), + "tool" => { + let name = object + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or(""); + json!({ + "mode": "ANY", + "allowedFunctionNames": [name] + }) + } + other => { + return Err(ProxyError::TransformError(format!( + "Unsupported Gemini tool_choice type: {other}" + ))); + } + }; + + Ok(Some(json!({ "functionCallingConfig": config }))) + } + _ => Ok(None), + } +} + +/// Convert a Gemini `usageMetadata` object into an Anthropic-style `usage` +/// object. Used by both the streaming SSE converter and the non-streaming +/// transform path so the two emit identical shapes. +pub(crate) fn build_anthropic_usage(usage: Option<&Value>) -> Value { + let Some(usage) = usage else { + return json!({ + "input_tokens": 0, + "output_tokens": 0 + }); + }; + + let input_tokens = usage + .get("promptTokenCount") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let total_tokens = usage + .get("totalTokenCount") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let output_tokens = total_tokens.saturating_sub(input_tokens); + + let mut result = json!({ + "input_tokens": input_tokens, + "output_tokens": output_tokens + }); + + if let Some(cached) = usage + .get("cachedContentTokenCount") + .and_then(|value| value.as_u64()) + { + result["cache_read_input_tokens"] = json!(cached); + } + + result +} + +fn map_finish_reason(reason: Option<&str>, has_tool_use: bool) -> Value { + let mapped = match reason { + Some("MAX_TOKENS") => Some("max_tokens"), + Some("STOP") | Some("FINISH_REASON_UNSPECIFIED") | None => { + if has_tool_use { + Some("tool_use") + } else { + Some("end_turn") + } + } + Some("SAFETY") + | Some("RECITATION") + | Some("SPII") + | Some("BLOCKLIST") + | Some("PROHIBITED_CONTENT") => Some("refusal"), + Some(other) => { + log::warn!("[Claude/Gemini] Unknown Gemini finishReason `{other}`, using end_turn"); + Some("end_turn") + } + }; + + match mapped { + Some(value) => json!(value), + None => Value::Null, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anthropic_to_gemini_maps_system_and_messages() { + let input = json!({ + "model": "gemini-2.5-pro", + "max_tokens": 128, + "system": "You are helpful.", + "messages": [ + { "role": "user", "content": "Hello" } + ] + }); + + let result = anthropic_to_gemini(input).unwrap(); + assert_eq!( + result["systemInstruction"]["parts"][0]["text"], + "You are helpful." + ); + assert_eq!(result["contents"][0]["role"], "user"); + assert_eq!(result["contents"][0]["parts"][0]["text"], "Hello"); + assert_eq!(result["generationConfig"]["maxOutputTokens"], 128); + } + + #[test] + fn anthropic_to_gemini_maps_tools_and_tool_results() { + let input = json!({ + "messages": [ + { + "role": "assistant", + "content": [ + { "type": "tool_use", "id": "call_1", "name": "get_weather", "input": { "city": "Tokyo" } } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": "call_1", "content": "Sunny" } + ] + } + ], + "tools": [ + { + "name": "get_weather", + "description": "Weather lookup", + "input_schema": { "type": "object", "properties": { "city": { "type": "string" } } } + } + ], + "tool_choice": { "type": "tool", "name": "get_weather" } + }); + + let result = anthropic_to_gemini(input).unwrap(); + assert_eq!( + result["tools"][0]["functionDeclarations"][0]["name"], + "get_weather" + ); + assert!(result["tools"][0]["functionDeclarations"][0] + .get("parameters") + .is_some()); + assert_eq!( + result["contents"][0]["parts"][0]["functionCall"]["name"], + "get_weather" + ); + assert_eq!( + result["contents"][1]["parts"][0]["functionResponse"]["name"], + "get_weather" + ); + assert_eq!( + result["toolConfig"]["functionCallingConfig"]["allowedFunctionNames"][0], + "get_weather" + ); + } + + #[test] + fn anthropic_to_gemini_resolves_tool_result_name_from_shadow_content() { + let store = GeminiShadowStore::with_limits(8, 4); + store.record_assistant_turn( + "provider-a", + "session-1", + json!({ + "parts": [{ + "functionCall": { + "id": "call_1", + "name": "get_weather", + "args": { "city": "Tokyo" } + } + }] + }), + vec![], + ); + + let input = json!({ + "messages": [ + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": "call_1", "content": "Sunny" } + ] + } + ] + }); + + let result = anthropic_to_gemini_with_shadow( + input, + Some(&store), + Some("provider-a"), + Some("session-1"), + ) + .unwrap(); + + assert_eq!( + result["contents"][0]["parts"][0]["functionResponse"]["name"], + "get_weather" + ); + } + + #[test] + fn anthropic_to_gemini_rejects_tool_result_without_resolvable_name() { + let input = json!({ + "messages": [ + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": "call_1", "content": "Sunny" } + ] + } + ] + }); + + let error = anthropic_to_gemini(input).unwrap_err(); + assert!(error + .to_string() + .contains("Unable to resolve Gemini functionResponse.name")); + } + + #[test] + fn anthropic_to_gemini_uses_parameters_json_schema_for_rich_tool_schema() { + let input = json!({ + "tools": [ + { + "name": "search", + "description": "Search data", + "input_schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "query": { "type": "string" } + }, + "required": ["query"], + "additionalProperties": false + } + } + ] + }); + + let result = anthropic_to_gemini(input).unwrap(); + let declaration = &result["tools"][0]["functionDeclarations"][0]; + + assert!(declaration.get("parameters").is_none()); + assert!(declaration.get("parametersJsonSchema").is_some()); + assert!(declaration["parametersJsonSchema"].get("$schema").is_none()); + assert_eq!( + declaration["parametersJsonSchema"]["additionalProperties"], + false + ); + } + + #[test] + fn gemini_to_anthropic_maps_text_and_usage() { + let input = json!({ + "responseId": "resp_1", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ "text": "Hello from Gemini" }] + } + }], + "usageMetadata": { + "promptTokenCount": 12, + "totalTokenCount": 20, + "cachedContentTokenCount": 3 + } + }); + + let result = gemini_to_anthropic(input).unwrap(); + assert_eq!(result["id"], "resp_1"); + assert_eq!(result["content"][0]["type"], "text"); + assert_eq!(result["content"][0]["text"], "Hello from Gemini"); + assert_eq!(result["stop_reason"], "end_turn"); + assert_eq!(result["usage"]["input_tokens"], 12); + assert_eq!(result["usage"]["output_tokens"], 8); + assert_eq!(result["usage"]["cache_read_input_tokens"], 3); + } + + #[test] + fn gemini_to_anthropic_maps_function_calls_to_tool_use() { + let input = json!({ + "responseId": "resp_2", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ + "functionCall": { + "id": "call_1", + "name": "get_weather", + "args": { "city": "Tokyo" } + } + }] + } + }], + "usageMetadata": { + "promptTokenCount": 10, + "totalTokenCount": 15 + } + }); + + let result = gemini_to_anthropic(input).unwrap(); + assert_eq!(result["content"][0]["type"], "tool_use"); + assert_eq!(result["content"][0]["id"], "call_1"); + assert_eq!(result["stop_reason"], "tool_use"); + } + + #[test] + fn gemini_to_anthropic_rectifies_tool_args_from_schema_hints() { + let input = json!({ + "responseId": "resp_2", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ + "functionCall": { + "id": "call_1", + "name": "Skill", + "args": { + "name": "git-commit", + "parameters": { + "args": ["详细分析内容 编写提交信息 分多次提交代码"] + } + } + } + }] + } + }] + }); + let hints = extract_anthropic_tool_schema_hints(&json!({ + "tools": [{ + "name": "Skill", + "input_schema": { + "type": "object", + "properties": { + "skill": { "type": "string" }, + "args": { "type": "string" } + }, + "required": ["skill"] + } + }] + })); + + let result = + gemini_to_anthropic_with_shadow_and_hints(input, None, None, None, Some(&hints)) + .unwrap(); + + assert_eq!(result["content"][0]["input"]["skill"], "git-commit"); + assert_eq!( + result["content"][0]["input"]["args"], + "详细分析内容 编写提交信息 分多次提交代码" + ); + assert!(result["content"][0]["input"].get("name").is_none()); + assert!(result["content"][0]["input"].get("parameters").is_none()); + } + + #[test] + fn gemini_to_anthropic_preserves_legitimate_parameters_arg() { + let input = json!({ + "responseId": "resp_params", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ + "functionCall": { + "id": "call_1", + "name": "ConfigTool", + "args": { + "parameters": { + "mode": "safe", + "retries": 2 + } + } + } + }] + } + }] + }); + let hints = extract_anthropic_tool_schema_hints(&json!({ + "tools": [{ + "name": "ConfigTool", + "input_schema": { + "type": "object", + "properties": { + "parameters": { + "type": "object", + "properties": { + "mode": { "type": "string" }, + "retries": { "type": "integer" } + } + } + }, + "required": ["parameters"] + } + }] + })); + + let result = + gemini_to_anthropic_with_shadow_and_hints(input, None, None, None, Some(&hints)) + .unwrap(); + + assert_eq!(result["content"][0]["input"]["parameters"]["mode"], "safe"); + assert_eq!(result["content"][0]["input"]["parameters"]["retries"], 2); + } + + #[test] + fn gemini_to_anthropic_maps_blocked_prompt_to_refusal() { + let input = json!({ + "responseId": "resp_3", + "modelVersion": "gemini-2.5-flash", + "promptFeedback": { "blockReason": "SAFETY" }, + "usageMetadata": { + "promptTokenCount": 4, + "totalTokenCount": 4 + } + }); + + let result = gemini_to_anthropic(input).unwrap(); + assert_eq!(result["stop_reason"], "refusal"); + assert_eq!(result["content"][0]["type"], "text"); + assert!(result["content"][0]["text"] + .as_str() + .unwrap() + .contains("SAFETY")); + } + + #[test] + fn shadow_replay_aligns_to_latest_turns_after_client_truncation() { + let store = GeminiShadowStore::with_limits(8, 4); + // Record 3 shadow turns (assistant messages 0, 1, 2) + for i in 0..3 { + store.record_assistant_turn( + "prov", + "sess", + json!({ + "parts": [{ + "functionCall": { + "id": format!("call_{i}"), + "name": format!("tool_{i}"), + "args": {} + } + }] + }), + vec![], + ); + } + + // Client truncates history: only sends assistant messages 1 and 2 + let input = json!({ + "messages": [ + { + "role": "assistant", + "content": [ + { "type": "tool_use", "id": "call_1", "name": "tool_1", "input": {} } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": "call_1", "content": "ok" } + ] + }, + { + "role": "assistant", + "content": [ + { "type": "tool_use", "id": "call_2", "name": "tool_2", "input": {} } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": "call_2", "content": "ok" } + ] + } + ] + }); + + let result = + anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess")) + .unwrap(); + + // Shadow turns[1] (tool_1) should align with first assistant message, + // shadow turns[2] (tool_2) with the second — not turns[0] and turns[1]. + assert_eq!( + result["contents"][0]["parts"][0]["functionCall"]["name"], + "tool_1" + ); + assert_eq!( + result["contents"][2]["parts"][0]["functionCall"]["name"], + "tool_2" + ); + } + + #[test] + fn shadow_replay_matches_tool_use_turn_by_id_when_position_drifts() { + let store = GeminiShadowStore::with_limits(8, 4); + store.record_assistant_turn( + "prov", + "sess", + json!({ + "parts": [{ + "functionCall": { + "id": "call_1", + "name": "Bash", + "args": { "command": "ls -R" } + }, + "thoughtSignature": "sig-tool-1" + }] + }), + vec![GeminiToolCallMeta::new( + Some("call_1"), + "Bash", + json!({ "command": "ls -R" }), + Some("sig-tool-1"), + )], + ); + + let input = json!({ + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "call_1", + "name": "default_api:Bash", + "input": { "command": "ls -R" } + } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": "call_1", "content": "ok" } + ] + }, + { + "role": "assistant", + "content": [ + { "type": "text", "text": "local-only assistant turn without Gemini shadow" } + ] + } + ] + }); + + let result = + anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess")) + .unwrap(); + + assert_eq!( + result["contents"][0]["parts"][0]["functionCall"]["name"], + "Bash" + ); + assert_eq!( + result["contents"][0]["parts"][0]["thoughtSignature"], + "sig-tool-1" + ); + } + + /// Regression for P1: two shadow turns whose suffix-normalized names + /// collide (e.g. `server_a:search` / `server_b:search` both normalize to + /// `search`). When the incoming assistant tool_use carries a valid, + /// different id, exact-id matching must win over the normalized-name + /// clause — otherwise replay picks the wrong shadow turn and later + /// tool_result resolution mis-routes. + #[test] + fn shadow_replay_prefers_exact_id_match_over_normalized_name_collision() { + let store = GeminiShadowStore::with_limits(8, 4); + store.record_assistant_turn( + "prov", + "sess", + json!({ + "parts": [{ + "functionCall": { + "id": "call_a", + "name": "server_a:search", + "args": { "q": "alpha" } + }, + "thoughtSignature": "sig-a" + }] + }), + vec![GeminiToolCallMeta::new( + Some("call_a"), + "server_a:search", + json!({ "q": "alpha" }), + Some("sig-a"), + )], + ); + store.record_assistant_turn( + "prov", + "sess", + json!({ + "parts": [{ + "functionCall": { + "id": "call_b", + "name": "server_b:search", + "args": { "q": "beta" } + }, + "thoughtSignature": "sig-b" + }] + }), + vec![GeminiToolCallMeta::new( + Some("call_b"), + "server_b:search", + json!({ "q": "beta" }), + Some("sig-b"), + )], + ); + + // Two assistant turns: the first references call_b, the second + // call_a. Positional fallback would align msg[0] to turn 0 (call_a) + // and msg[1] to turn 1 (call_b) — both wrong. The old `||` chain + // would also mis-match through the normalized "search" name. + let input = json!({ + "messages": [ + { + "role": "assistant", + "content": [ + { "type": "tool_use", "id": "call_b", "name": "server_b:search", "input": { "q": "beta" } } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": "call_b", "content": "ok-b" } + ] + }, + { + "role": "assistant", + "content": [ + { "type": "tool_use", "id": "call_a", "name": "server_a:search", "input": { "q": "alpha" } } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": "call_a", "content": "ok-a" } + ] + } + ] + }); + + let result = + anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess")) + .unwrap(); + + // msg[0] replays shadow turn 1 (server_b:search) because id=call_b. + assert_eq!( + result["contents"][0]["parts"][0]["functionCall"]["name"], + "server_b:search" + ); + assert_eq!( + result["contents"][0]["parts"][0]["thoughtSignature"], + "sig-b" + ); + // msg[2] replays shadow turn 0 (server_a:search) because id=call_a, + // even though turn 1 was already consumed above. + assert_eq!( + result["contents"][2]["parts"][0]["functionCall"]["name"], + "server_a:search" + ); + assert_eq!( + result["contents"][2]["parts"][0]["thoughtSignature"], + "sig-a" + ); + } + + /// When the incoming tool_use carries no id (or only empty-string ids), + /// the layered matcher must still fall back to name-based matching so + /// that shadow replay keeps working for providers that omit ids. + #[test] + fn shadow_replay_falls_back_to_name_when_ids_absent() { + let store = GeminiShadowStore::with_limits(8, 4); + store.record_assistant_turn( + "prov", + "sess", + json!({ + "parts": [{ + "functionCall": { + "name": "lookup", + "args": {} + }, + "thoughtSignature": "sig-lookup" + }] + }), + vec![GeminiToolCallMeta::new( + None::<&str>, + "lookup", + json!({}), + Some("sig-lookup"), + )], + ); + + // id is an empty string; extract_assistant_tool_use_keys filters it + // out, so tool_use_ids is empty and matching must go through names. + // A trailing user text turn keeps the assistant turn well-formed + // without feeding a tool_result back (which would require a real id). + let input = json!({ + "messages": [ + { + "role": "assistant", + "content": [ + { "type": "tool_use", "id": "", "name": "lookup", "input": {} } + ] + }, + { + "role": "user", + "content": "ack" + } + ] + }); + + let result = + anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess")) + .unwrap(); + + assert_eq!( + result["contents"][0]["parts"][0]["functionCall"]["name"], + "lookup" + ); + assert_eq!( + result["contents"][0]["parts"][0]["thoughtSignature"], + "sig-lookup" + ); + } + + /// Regression for P1: Gemini 2.x may return parallel calls without ids. + /// Each Anthropic-visible tool_use must carry a unique id so the Claude + /// Code client can map tool_result responses back correctly. + #[test] + fn gemini_to_anthropic_synthesizes_unique_ids_for_missing_functioncall_ids() { + let input = json!({ + "responseId": "r1", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [ + { "functionCall": { "name": "foo", "args": {} } }, + { "functionCall": { "name": "foo", "args": { "k": 1 } } } + ] + } + }] + }); + + let result = gemini_to_anthropic(input).unwrap(); + let id0 = result["content"][0]["id"].as_str().unwrap(); + let id1 = result["content"][1]["id"].as_str().unwrap(); + assert!(is_synthesized_tool_call_id(id0)); + assert!(is_synthesized_tool_call_id(id1)); + assert_ne!(id0, id1, "synthesized ids must be unique per call"); + } + + /// Ensures the proxy does not leak synthesized ids back to Gemini when + /// Claude Code replies with a tool_result: the id must be stripped from + /// both `functionCall.id` and `functionResponse.id`. + #[test] + fn tool_result_with_synthesized_id_omits_id_in_gemini_request() { + let synth = synthesize_tool_call_id(); + let input = json!({ + "messages": [ + { + "role": "assistant", + "content": [ + { "type": "tool_use", "id": &synth, "name": "get_weather", "input": { "city": "X" } } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": &synth, "content": "sunny" } + ] + } + ] + }); + + let result = anthropic_to_gemini(input).unwrap(); + let fc = &result["contents"][0]["parts"][0]["functionCall"]; + assert!( + fc.get("id").is_none(), + "synthesized id must not leak upstream in functionCall" + ); + assert_eq!(fc["name"], "get_weather"); + let fr = &result["contents"][1]["parts"][0]["functionResponse"]; + assert!( + fr.get("id").is_none(), + "synthesized id must not leak upstream in functionResponse" + ); + assert_eq!(fr["name"], "get_weather"); + } + + /// Genuine Gemini-assigned ids must round-trip unchanged so that Gemini + /// can correlate the tool result with its own prior functionCall entry. + #[test] + fn tool_result_with_genuine_gemini_id_round_trips() { + let input = json!({ + "messages": [ + { + "role": "assistant", + "content": [ + { "type": "tool_use", "id": "call_real_1", "name": "get_weather", "input": {} } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": "call_real_1", "content": "ok" } + ] + } + ] + }); + + let result = anthropic_to_gemini(input).unwrap(); + assert_eq!( + result["contents"][0]["parts"][0]["functionCall"]["id"], + "call_real_1" + ); + assert_eq!( + result["contents"][1]["parts"][0]["functionResponse"]["id"], + "call_real_1" + ); + } + + /// Shadow replay must also strip synthesized ids when it reconstructs + /// the assistant's `functionCall` parts from a previously recorded turn. + #[test] + fn shadow_replay_strips_synthesized_id_from_function_call() { + let store = GeminiShadowStore::with_limits(8, 4); + let synth = synthesize_tool_call_id(); + store.record_assistant_turn( + "prov", + "sess", + json!({ + "parts": [{ + "functionCall": { + "id": &synth, + "name": "get_weather", + "args": { "city": "Tokyo" } + } + }] + }), + vec![GeminiToolCallMeta::new( + Some(synth.clone()), + "get_weather", + json!({ "city": "Tokyo" }), + None::, + )], + ); + + let input = json!({ + "messages": [ + { + "role": "assistant", + "content": [ + { "type": "tool_use", "id": &synth, "name": "get_weather", "input": { "city": "Tokyo" } } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": &synth, "content": "sunny" } + ] + } + ] + }); + + let result = + anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess")) + .unwrap(); + // The assistant message was replayed from shadow; its synthesized id + // must be absent from the upstream functionCall representation. + assert!(result["contents"][0]["parts"][0]["functionCall"] + .get("id") + .is_none()); + // And the tool_result round-trip must still resolve the name via the + // shadow map even when the id is synthesized. + assert_eq!( + result["contents"][1]["parts"][0]["functionResponse"]["name"], + "get_weather" + ); + } + + // ------------------------------------------------------------------ + // Non-streaming shadow id coherence regressions. + // + // When Gemini returns a `functionCall` without an id (common in 2.x + // parallel calls) the proxy must synthesize a single id that is + // consistent across: + // (a) the Anthropic `content[tool_use].id` sent to the client + // (b) `shadow_content.parts[].functionCall.id` recorded in shadow + // (c) `shadow_turn.tool_calls[].id` recorded in shadow + // Previously the non-streaming path generated independent UUIDs in (a) + // and (c), so the next round's `tool_result(tool_use_id=A)` would + // fail to resolve through `tool_name_by_id` (populated from (c)). + // ------------------------------------------------------------------ + + /// The id surfaced to the Anthropic client must equal the id recorded + /// in the shadow's `tool_calls` metadata and the shadow's serialized + /// `functionCall.id`. All three are read back as the same string. + #[test] + fn non_stream_shadow_id_matches_client_visible_id() { + let store = GeminiShadowStore::with_limits(8, 4); + let body = json!({ + "responseId": "r-coherence", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ + "functionCall": { "name": "get_weather", "args": { "city": "Tokyo" } } + }] + } + }] + }); + + let response = gemini_to_anthropic_with_shadow_and_hints( + body, + Some(&store), + Some("prov"), + Some("sess"), + None, + ) + .unwrap(); + + let client_id = response["content"][0]["id"].as_str().unwrap(); + assert!( + is_synthesized_tool_call_id(client_id), + "client-facing id must be synthesized for no-id Gemini responses" + ); + + let snapshot = store.get_session("prov", "sess").expect("shadow recorded"); + // (c) tool_calls metadata must agree with the client-visible id. + let shadow_tool_call_id = snapshot.turns[0].tool_calls[0] + .id + .as_deref() + .expect("tool_calls id populated"); + assert_eq!( + shadow_tool_call_id, client_id, + "shadow.tool_calls id must equal client-visible id" + ); + // (b) assistant_content parts must agree too, so that + // `merge_tool_names_from_parts` sees the same id on replay. + let shadow_part_id = snapshot.turns[0].assistant_content["parts"][0]["functionCall"]["id"] + .as_str() + .expect("assistant_content functionCall id populated"); + assert_eq!( + shadow_part_id, client_id, + "shadow assistant_content functionCall.id must equal client-visible id" + ); + } + + /// Scenario A: the client-side history was truncated so the next + /// request only contains `[tool_result(tool_use_id=A)]` without a + /// preceding assistant echo. The request must still resolve because + /// `build_tool_name_map_from_shadow_turns` now surfaces the same id + /// the client was given. + #[test] + fn non_stream_missing_id_scenario_a_truncated_history_resolves() { + let store = GeminiShadowStore::with_limits(8, 4); + let turn1 = json!({ + "responseId": "r-truncated", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ + "functionCall": { "name": "get_weather", "args": { "city": "Tokyo" } } + }] + } + }] + }); + let anthropic_response = gemini_to_anthropic_with_shadow_and_hints( + turn1, + Some(&store), + Some("prov"), + Some("sess"), + None, + ) + .unwrap(); + let client_id = anthropic_response["content"][0]["id"] + .as_str() + .unwrap() + .to_string(); + + // Turn 2 — client replays ONLY the tool_result. No assistant echo. + let turn2_input = json!({ + "messages": [ + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": &client_id, "content": "sunny" } + ] + } + ] + }); + let result = + anthropic_to_gemini_with_shadow(turn2_input, Some(&store), Some("prov"), Some("sess")) + .expect("scenario A must resolve tool name through shadow"); + assert_eq!( + result["contents"][0]["parts"][0]["functionResponse"]["name"], + "get_weather" + ); + } + + /// Scenario B: the client replays the full history. The proxy picks + /// the shadow-replay branch (not `convert_message_content_to_parts`), + /// which strips the synthesized id from the outgoing `functionCall`. + /// `tool_name_by_id` must still have been populated from the shadow + /// so the following `tool_result(A)` resolves. + #[test] + fn non_stream_missing_id_scenario_b_full_history_replay_resolves() { + let store = GeminiShadowStore::with_limits(8, 4); + let turn1 = json!({ + "responseId": "r-full", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ + "functionCall": { "name": "get_weather", "args": { "city": "Tokyo" } } + }] + } + }] + }); + let anthropic_response = gemini_to_anthropic_with_shadow_and_hints( + turn1, + Some(&store), + Some("prov"), + Some("sess"), + None, + ) + .unwrap(); + let client_id = anthropic_response["content"][0]["id"] + .as_str() + .unwrap() + .to_string(); + + // Turn 2 — full history: assistant tool_use + tool_result. + let turn2_input = json!({ + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": &client_id, + "name": "get_weather", + "input": { "city": "Tokyo" } + } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": &client_id, "content": "sunny" } + ] + } + ] + }); + let result = + anthropic_to_gemini_with_shadow(turn2_input, Some(&store), Some("prov"), Some("sess")) + .expect("scenario B must resolve tool name through shadow replay"); + + // Shadow-replay path: `functionCall.id` is stripped for the + // assistant turn (the synthesized id must not leak upstream). + assert!( + result["contents"][0]["parts"][0]["functionCall"] + .get("id") + .is_none(), + "synthesized id must not leak to Gemini in shadow replay" + ); + assert_eq!( + result["contents"][0]["parts"][0]["functionCall"]["name"], + "get_weather" + ); + // The tool_result round-trip resolves through the shadow map. + assert_eq!( + result["contents"][1]["parts"][0]["functionResponse"]["name"], + "get_weather" + ); + } + + /// Regression: when Gemini returns an id, nothing is synthesized. + /// The original id is round-tripped in both the Anthropic response + /// and the shadow store, and it flows back to Gemini on the next + /// functionResponse. + #[test] + fn non_stream_preserves_original_gemini_id_when_present() { + let store = GeminiShadowStore::with_limits(8, 4); + let body = json!({ + "responseId": "r-preserve", + "modelVersion": "gemini-2.5-pro", + "candidates": [{ + "finishReason": "STOP", + "content": { + "parts": [{ + "functionCall": { + "id": "call_real_1", + "name": "get_weather", + "args": { "city": "Tokyo" } + } + }] + } + }] + }); + + let response = gemini_to_anthropic_with_shadow_and_hints( + body, + Some(&store), + Some("prov"), + Some("sess"), + None, + ) + .unwrap(); + assert_eq!(response["content"][0]["id"], "call_real_1"); + let snapshot = store.get_session("prov", "sess").unwrap(); + assert_eq!( + snapshot.turns[0].tool_calls[0].id.as_deref(), + Some("call_real_1") + ); + assert_eq!( + snapshot.turns[0].assistant_content["parts"][0]["functionCall"]["id"], + "call_real_1" + ); + } + + /// Defensive: if a shadow turn somehow carries a synthesized + /// `functionCall.id` (e.g. recorded by this path), replaying it via + /// `anthropic_to_gemini_with_shadow` must strip the id before sending + /// upstream, so Gemini never sees the internal identifier. + #[test] + fn non_stream_synthesized_id_not_leaked_to_gemini_via_shadow_replay() { + let store = GeminiShadowStore::with_limits(8, 4); + let synth = synthesize_tool_call_id(); + store.record_assistant_turn( + "prov", + "sess", + json!({ + "parts": [{ + "functionCall": { + "id": &synth, + "name": "get_weather", + "args": { "city": "Tokyo" } + } + }] + }), + vec![GeminiToolCallMeta::new( + Some(synth.clone()), + "get_weather", + json!({ "city": "Tokyo" }), + None::, + )], + ); + + let input = json!({ + "messages": [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": &synth, + "name": "get_weather", + "input": { "city": "Tokyo" } + } + ] + }, + { + "role": "user", + "content": [ + { "type": "tool_result", "tool_use_id": &synth, "content": "sunny" } + ] + } + ] + }); + let result = + anthropic_to_gemini_with_shadow(input, Some(&store), Some("prov"), Some("sess")) + .unwrap(); + assert!( + result["contents"][0]["parts"][0]["functionCall"] + .get("id") + .is_none(), + "shadow replay must strip synthesized functionCall.id" + ); + assert!( + result["contents"][1]["parts"][0]["functionResponse"] + .get("id") + .is_none(), + "functionResponse.id must also be omitted for synthesized ids" + ); + } +} diff --git a/src-tauri/src/proxy/providers/transform_responses.rs b/src-tauri/src/proxy/providers/transform_responses.rs index a61bf7f9b..8a7038b85 100644 --- a/src-tauri/src/proxy/providers/transform_responses.rs +++ b/src-tauri/src/proxy/providers/transform_responses.rs @@ -194,23 +194,14 @@ pub(crate) fn map_responses_stop_reason( incomplete_reason: Option<&str>, ) -> Option<&'static str> { status.map(|s| match s { - "completed" => { - if has_tool_use { - "tool_use" - } else { - "end_turn" - } - } - "incomplete" => { + "completed" if has_tool_use => "tool_use", + "incomplete" if matches!( incomplete_reason, Some("max_output_tokens") | Some("max_tokens") - ) || incomplete_reason.is_none() - { - "max_tokens" - } else { - "end_turn" - } + ) || incomplete_reason.is_none() => + { + "max_tokens" } _ => "end_turn", }) diff --git a/src-tauri/src/proxy/response_handler.rs b/src-tauri/src/proxy/response_handler.rs index 502ed289f..d40ec0b06 100644 --- a/src-tauri/src/proxy/response_handler.rs +++ b/src-tauri/src/proxy/response_handler.rs @@ -5,7 +5,7 @@ use super::session::ProxySession; use super::usage::parser::TokenUsage; use super::ProxyError; -use crate::proxy::sse::strip_sse_field; +use crate::proxy::sse::{strip_sse_field, take_sse_block}; use bytes::Bytes; use futures::stream::{Stream, StreamExt}; use serde_json::Value; @@ -86,10 +86,7 @@ impl StreamHandler { crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes); // 提取完整事件 - while let Some(pos) = buffer.find("\n\n") { - let event_text = buffer[..pos].to_string(); - buffer = buffer[pos + 2..].to_string(); - + while let Some(event_text) = take_sse_block(&mut buffer) { for line in event_text.lines() { if let Some(data) = strip_sse_field(line, "data") { if data.trim() != "[DONE]" { diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 3008bb5fd..0b9f19f82 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -7,7 +7,7 @@ use super::{ handler_context::{RequestContext, StreamingTimeoutConfig}, hyper_client::ProxyResponse, server::ProxyState, - sse::strip_sse_field, + sse::{strip_sse_field, take_sse_block}, usage::parser::TokenUsage, ProxyError, }; @@ -662,10 +662,7 @@ pub fn create_logged_passthrough_stream( crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes); // 尝试解析并记录完整的 SSE 事件 - while let Some(pos) = buffer.find("\n\n") { - let event_text = buffer[..pos].to_string(); - buffer = buffer[pos + 2..].to_string(); - + while let Some(event_text) = take_sse_block(&mut buffer) { if !event_text.trim().is_empty() { // 提取 data 部分并尝试解析为 JSON for line in event_text.lines() { @@ -726,6 +723,7 @@ mod tests { use crate::provider::ProviderMeta; use crate::proxy::failover_switch::FailoverSwitchManager; use crate::proxy::provider_router::ProviderRouter; + use crate::proxy::providers::gemini_shadow::GeminiShadowStore; use crate::proxy::types::{ProxyConfig, ProxyStatus}; use rust_decimal::Decimal; use std::collections::HashMap; @@ -846,6 +844,7 @@ mod tests { start_time: Arc::new(RwLock::new(None)), current_providers: Arc::new(RwLock::new(HashMap::new())), provider_router: Arc::new(ProviderRouter::new(db.clone())), + gemini_shadow: Arc::new(GeminiShadowStore::default()), app_handle: None, failover_manager: Arc::new(FailoverSwitchManager::new(db)), } diff --git a/src-tauri/src/proxy/server.rs b/src-tauri/src/proxy/server.rs index c67312526..1360aae03 100644 --- a/src-tauri/src/proxy/server.rs +++ b/src-tauri/src/proxy/server.rs @@ -10,7 +10,8 @@ use super::{ failover_switch::FailoverSwitchManager, handlers, log_codes::srv as log_srv, - provider_router::ProviderRouter, types::*, ProxyError, + provider_router::ProviderRouter, providers::gemini_shadow::GeminiShadowStore, types::*, + ProxyError, }; use crate::database::Database; use axum::{ @@ -35,6 +36,8 @@ pub struct ProxyState { pub current_providers: Arc>>, /// 共享的 ProviderRouter(持有熔断器状态,跨请求保持) pub provider_router: Arc, + /// Gemini Native shadow state,用于 thoughtSignature / tool call 回放 + pub gemini_shadow: Arc, /// AppHandle,用于发射事件和更新托盘菜单 pub app_handle: Option, /// 故障转移切换管理器 @@ -68,6 +71,7 @@ impl ProxyServer { start_time: Arc::new(RwLock::new(None)), current_providers: Arc::new(RwLock::new(std::collections::HashMap::new())), provider_router, + gemini_shadow: Arc::new(GeminiShadowStore::default()), app_handle, failover_manager, }; diff --git a/src-tauri/src/proxy/session.rs b/src-tauri/src/proxy/session.rs index cd94a034d..c5a786aa0 100644 --- a/src-tauri/src/proxy/session.rs +++ b/src-tauri/src/proxy/session.rs @@ -242,6 +242,12 @@ pub fn extract_session_id( body: &serde_json::Value, client_format: &str, ) -> SessionIdResult { + if client_format == "claude" { + if let Some(result) = extract_claude_session(headers, body) { + return result; + } + } + // Codex 请求特殊处理 if client_format == "codex" || client_format == "openai" { if let Some(result) = extract_codex_session(headers, body) { @@ -258,6 +264,28 @@ pub fn extract_session_id( generate_new_session_id() } +/// 提取 Claude Session ID +fn extract_claude_session( + headers: &HeaderMap, + body: &serde_json::Value, +) -> Option { + for header_name in &["x-claude-code-session-id", "claude-code-session-id"] { + if let Some(value) = headers.get(*header_name) { + if let Ok(session_id) = value.to_str() { + if !session_id.is_empty() { + return Some(SessionIdResult { + session_id: session_id.to_string(), + source: SessionIdSource::Header, + client_provided: true, + }); + } + } + } + } + + extract_from_metadata(body) +} + /// 提取 Codex Session ID fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option { // 1. 从 headers 提取 @@ -515,6 +543,47 @@ mod tests { assert!(result.client_provided); } + #[test] + fn test_extract_session_from_claude_header() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-claude-code-session-id", + "d937243f-2702-4f20-97b6-c9682235ab81".parse().unwrap(), + ); + let body = json!({ + "model": "claude-3-5-sonnet", + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = extract_session_id(&headers, &body, "claude"); + + assert_eq!(result.session_id, "d937243f-2702-4f20-97b6-c9682235ab81"); + assert_eq!(result.source, SessionIdSource::Header); + assert!(result.client_provided); + } + + #[test] + fn test_extract_session_from_claude_header_precedes_metadata() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-claude-code-session-id", + "header-session-123".parse().unwrap(), + ); + let body = json!({ + "model": "claude-3-5-sonnet", + "messages": [{"role": "user", "content": "Hello"}], + "metadata": { + "session_id": "my-session-123" + } + }); + + let result = extract_session_id(&headers, &body, "claude"); + + assert_eq!(result.session_id, "header-session-123"); + assert_eq!(result.source, SessionIdSource::Header); + assert!(result.client_provided); + } + #[test] fn test_extract_session_from_codex_previous_response_id() { let headers = HeaderMap::new(); diff --git a/src-tauri/src/proxy/sse.rs b/src-tauri/src/proxy/sse.rs index f26a6a711..adb259c97 100644 --- a/src-tauri/src/proxy/sse.rs +++ b/src-tauri/src/proxy/sse.rs @@ -4,6 +4,24 @@ pub(crate) fn strip_sse_field<'a>(line: &'a str, field: &str) -> Option<&'a str> .or_else(|| line.strip_prefix(&format!("{field}:"))) } +#[inline] +pub(crate) fn take_sse_block(buffer: &mut String) -> Option { + let mut best: Option<(usize, usize)> = None; + + for (delimiter, len) in [("\r\n\r\n", 4usize), ("\n\n", 2usize)] { + if let Some(pos) = buffer.find(delimiter) { + if best.is_none_or(|(best_pos, _)| pos < best_pos) { + best = Some((pos, len)); + } + } + } + + let (pos, len) = best?; + let block = buffer[..pos].to_string(); + buffer.drain(..pos + len); + Some(block) +} + /// Append raw bytes to a UTF-8 `String` buffer, correctly handling multi-byte /// characters that are split across chunk boundaries. /// @@ -68,7 +86,7 @@ pub(crate) fn append_utf8_safe(buffer: &mut String, remainder: &mut Vec, new #[cfg(test)] mod tests { - use super::{append_utf8_safe, strip_sse_field}; + use super::{append_utf8_safe, strip_sse_field, take_sse_block}; #[test] fn strip_sse_field_accepts_optional_space() { @@ -91,6 +109,28 @@ mod tests { assert_eq!(strip_sse_field("id:1", "data"), None); } + #[test] + fn take_sse_block_supports_lf_delimiters() { + let mut buffer = "data: {\"ok\":true}\n\nrest".to_string(); + + assert_eq!( + take_sse_block(&mut buffer), + Some("data: {\"ok\":true}".to_string()) + ); + assert_eq!(buffer, "rest"); + } + + #[test] + fn take_sse_block_supports_crlf_delimiters() { + let mut buffer = "data: {\"ok\":true}\r\n\r\nrest".to_string(); + + assert_eq!( + take_sse_block(&mut buffer), + Some("data: {\"ok\":true}".to_string()) + ); + assert_eq!(buffer, "rest"); + } + // ------------------------------------------------------------------ // append_utf8_safe tests // ------------------------------------------------------------------ diff --git a/src-tauri/src/proxy/thinking_rectifier.rs b/src-tauri/src/proxy/thinking_rectifier.rs index ce71503c8..b43b4d8ea 100644 --- a/src-tauri/src/proxy/thinking_rectifier.rs +++ b/src-tauri/src/proxy/thinking_rectifier.rs @@ -52,6 +52,14 @@ pub fn should_rectify_thinking_signature( return true; } + // 场景1b: Gemini/第三方渠道返回 "Thought signature is not valid" + // 错误示例: "Unable to submit request because Thought signature is not valid" + if lower.contains("thought signature") + && (lower.contains("not valid") || lower.contains("invalid")) + { + return true; + } + // 场景2: assistant 消息必须以 thinking block 开头 // 错误示例: "must start with a thinking block" if lower.contains("must start with a thinking block") { @@ -280,6 +288,16 @@ mod tests { )); } + #[test] + fn test_detect_invalid_thought_signature_message() { + assert!(should_rectify_thinking_signature( + Some( + "Unable to submit request because Thought signature is not valid.. Learn more: https://example.com/help" + ), + &enabled_config() + )); + } + #[test] fn test_detect_invalid_signature_nested_json() { // 测试嵌套 JSON 格式的错误消息(第三方渠道常见格式) @@ -290,6 +308,15 @@ mod tests { )); } + #[test] + fn test_detect_invalid_thought_signature_nested_json() { + let nested_error = r#"{"error":{"message":"Unable to submit request because Thought signature is not valid.. Learn more: https://example.com/help","type":"upstream_error","param":"","code":400}}"#; + assert!(should_rectify_thinking_signature( + Some(nested_error), + &enabled_config() + )); + } + #[test] fn test_detect_thinking_expected() { assert!(should_rectify_thinking_signature( diff --git a/src-tauri/src/services/provider/endpoints.rs b/src-tauri/src/services/provider/endpoints.rs index b9e97ae7d..4a7894aa0 100644 --- a/src-tauri/src/services/provider/endpoints.rs +++ b/src-tauri/src/services/provider/endpoints.rs @@ -27,7 +27,7 @@ pub fn get_custom_endpoints( } let mut result: Vec<_> = meta.custom_endpoints.values().cloned().collect(); - result.sort_by(|a, b| b.added_at.cmp(&a.added_at)); + result.sort_by_key(|ep| std::cmp::Reverse(ep.added_at)); Ok(result) } diff --git a/src-tauri/src/services/session_usage_codex.rs b/src-tauri/src/services/session_usage_codex.rs index bd92e2ea1..8360c7ecf 100644 --- a/src-tauri/src/services/session_usage_codex.rs +++ b/src-tauri/src/services/session_usage_codex.rs @@ -297,18 +297,16 @@ fn sync_single_codex_file(db: &Database, file_path: &Path) -> Result<(u32, u32), }; match event_type { - "session_meta" => { - if state.session_id.is_none() { - let payload = value.get("payload"); - state.session_id = payload - .and_then(|p| { - p.get("session_id") - .or_else(|| p.get("sessionId")) - .or_else(|| p.get("id")) - }) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - } + "session_meta" if state.session_id.is_none() => { + let payload = value.get("payload"); + state.session_id = payload + .and_then(|p| { + p.get("session_id") + .or_else(|| p.get("sessionId")) + .or_else(|| p.get("id")) + }) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); } "turn_context" => { if let Some(payload) = value.get("payload") { diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 2acbdfae9..bce8dd6e3 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -1268,7 +1268,7 @@ impl SkillService { } } - entries.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + entries.sort_by_key(|entry| std::cmp::Reverse(entry.created_at)); Ok(entries) } @@ -1772,7 +1772,7 @@ impl SkillService { let results: Vec>> = futures::future::join_all(fetch_tasks).await; - for (repo, result) in enabled_repos.into_iter().zip(results.into_iter()) { + for (repo, result) in enabled_repos.into_iter().zip(results) { match result { Ok(repo_skills) => skills.extend(repo_skills), Err(e) => log::warn!("获取仓库 {}/{} 技能失败: {}", repo.owner, repo.name, e), @@ -1781,7 +1781,7 @@ impl SkillService { // 去重并排序 Self::deduplicate_discoverable_skills(&mut skills); - skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + skills.sort_by_key(|skill| skill.name.to_lowercase()); Ok(skills) } @@ -1848,7 +1848,7 @@ impl SkillService { } } - skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + skills.sort_by_key(|skill| skill.name.to_lowercase()); Ok(skills) } diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index de1e4cc23..1bb630058 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -12,8 +12,10 @@ use std::time::Instant; use crate::app_config::AppType; use crate::error::AppError; use crate::provider::Provider; +use crate::proxy::gemini_url::{normalize_gemini_model_id, resolve_gemini_native_url}; use crate::proxy::providers::copilot_auth; use crate::proxy::providers::transform::anthropic_to_openai; +use crate::proxy::providers::transform_gemini::anthropic_to_gemini; use crate::proxy::providers::transform_responses::anthropic_to_responses; use crate::proxy::providers::{get_adapter, AuthInfo, AuthStrategy}; @@ -288,6 +290,8 @@ impl StreamCheckService { /// 根据供应商的 api_format 选择请求格式: /// - "anthropic" (默认): Anthropic Messages API (/v1/messages) /// - "openai_chat": OpenAI Chat Completions API (/v1/chat/completions) + /// - "openai_responses": OpenAI Responses API (/v1/responses) + /// - "gemini_native": Gemini Native streamGenerateContent /// /// `extra_headers` 是一个可选的供应商级自定义 header 集合(从 OpenClaw /// 的 `settings_config.headers` 或 OpenCode 的 `settings_config.options.headers` @@ -329,8 +333,14 @@ impl StreamCheckService { .unwrap_or(false); let is_openai_chat = effective_api_format == "openai_chat"; let is_openai_responses = effective_api_format == "openai_responses"; - let url = - Self::resolve_claude_stream_url(base, auth.strategy, effective_api_format, is_full_url); + let is_gemini_native = effective_api_format == "gemini_native"; + let url = Self::resolve_claude_stream_url( + base, + auth.strategy, + effective_api_format, + is_full_url, + model, + ); let max_tokens = if is_openai_responses { 16 } else { 1 }; @@ -352,6 +362,9 @@ impl StreamCheckService { let body = if is_openai_responses { anthropic_to_responses(anthropic_body, Some(&provider.id), is_codex_oauth) .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? + } else if is_gemini_native { + anthropic_to_gemini(anthropic_body) + .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? } else if is_openai_chat { anthropic_to_openai(anthropic_body) .map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))? @@ -387,6 +400,23 @@ impl StreamCheckService { .header("x-vscode-user-agent-library-version", "electron-fetch") .header("x-request-id", &request_id) .header("x-agent-task-id", &request_id); + } else if is_gemini_native { + request_builder = match auth.strategy { + AuthStrategy::GoogleOAuth => { + let token = auth.access_token.as_ref().unwrap_or(&auth.api_key); + request_builder + .header("authorization", format!("Bearer {token}")) + .header("x-goog-api-client", "GeminiCLI/1.0") + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .header("accept-encoding", "identity") + } + _ => request_builder + .header("x-goog-api-key", &auth.api_key) + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .header("accept-encoding", "identity"), + }; } else if is_openai_chat || is_openai_responses { // OpenAI-compatible targets: Bearer auth + SSE headers only request_builder = request_builder @@ -568,13 +598,16 @@ impl StreamCheckService { extra_headers: Option<&serde_json::Map>, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); + // Strip `models/` resource-name prefix from the model id — see + // `normalize_gemini_model_id` for rationale. + let normalized_model = normalize_gemini_model_id(model); // Gemini 原生 API: /v1beta/models/{model}:streamGenerateContent?alt=sse // 智能处理 /v1beta 路径:如果 base_url 不包含版本路径,则添加 /v1beta // alt=sse 参数使 API 返回 SSE 格式(text/event-stream)而非 JSON 数组 let url = if base.contains("/v1beta") || base.contains("/v1/") { - format!("{base}/models/{model}:streamGenerateContent?alt=sse") + format!("{base}/models/{normalized_model}:streamGenerateContent?alt=sse") } else { - format!("{base}/v1beta/models/{model}:streamGenerateContent?alt=sse") + format!("{base}/v1beta/models/{normalized_model}:streamGenerateContent?alt=sse") }; // Gemini 原生请求体格式 @@ -1292,7 +1325,19 @@ impl StreamCheckService { auth_strategy: AuthStrategy, api_format: &str, is_full_url: bool, + model: &str, ) -> String { + if api_format == "gemini_native" { + // Strip an optional `models/` resource-name prefix so that model + // identifiers copied from Gemini SDK outputs (e.g. + // `models/gemini-2.5-pro`) don't produce a doubled + // `/v1beta/models/models/...` URL. + let normalized_model = normalize_gemini_model_id(model); + let endpoint = + format!("/v1beta/models/{normalized_model}:streamGenerateContent?alt=sse"); + return resolve_gemini_native_url(base_url, &endpoint, is_full_url); + } + if is_full_url { return base_url.to_string(); } @@ -1621,6 +1666,7 @@ mod tests { AuthStrategy::Bearer, "openai_chat", true, + "gpt-5.4", ); assert_eq!(url, "https://relay.example/v1/chat/completions"); @@ -1633,6 +1679,7 @@ mod tests { AuthStrategy::GitHubCopilot, "openai_chat", false, + "gpt-5.4", ); assert_eq!(url, "https://api.githubcopilot.com/chat/completions"); @@ -1645,6 +1692,7 @@ mod tests { AuthStrategy::GitHubCopilot, "openai_responses", false, + "gpt-5.4", ); assert_eq!(url, "https://api.githubcopilot.com/v1/responses"); @@ -1657,6 +1705,7 @@ mod tests { AuthStrategy::Bearer, "openai_chat", false, + "gpt-5.4", ); assert_eq!(url, "https://example.com/v1/chat/completions"); @@ -1669,6 +1718,7 @@ mod tests { AuthStrategy::Bearer, "openai_responses", false, + "gpt-5.4", ); assert_eq!(url, "https://example.com/v1/responses"); @@ -1681,11 +1731,78 @@ mod tests { AuthStrategy::Anthropic, "anthropic", false, + "claude-sonnet-4-6", ); assert_eq!(url, "https://api.anthropic.com/v1/messages"); } + #[test] + fn test_resolve_claude_stream_url_for_gemini_native() { + let url = StreamCheckService::resolve_claude_stream_url( + "https://generativelanguage.googleapis.com", + AuthStrategy::Google, + "gemini_native", + false, + "gemini-2.5-flash", + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + #[test] + fn test_resolve_claude_stream_url_for_gemini_native_full_url_openai_compat_base() { + let url = StreamCheckService::resolve_claude_stream_url( + "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", + AuthStrategy::Google, + "gemini_native", + true, + "gemini-2.5-flash", + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse" + ); + } + + #[test] + fn test_resolve_claude_stream_url_for_gemini_native_opaque_full_url() { + let url = StreamCheckService::resolve_claude_stream_url( + "https://relay.example/custom/generate-content", + AuthStrategy::Google, + "gemini_native", + true, + "gemini-2.5-flash", + ); + + assert_eq!(url, "https://relay.example/custom/generate-content?alt=sse"); + } + + /// Regression: Gemini SDK outputs commonly surface model ids as the + /// resource-name form `models/gemini-2.5-pro`. Interpolating that raw + /// value used to produce `/v1beta/models/models/gemini-2.5-pro:...` + /// which the upstream rejects and the health check records as a + /// false-negative for an otherwise valid provider. + #[test] + fn test_resolve_claude_stream_url_for_gemini_native_strips_models_prefix() { + let url = StreamCheckService::resolve_claude_stream_url( + "https://generativelanguage.googleapis.com", + AuthStrategy::Google, + "gemini_native", + false, + "models/gemini-2.5-pro", + ); + + assert_eq!( + url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse" + ); + } + #[test] fn test_resolve_codex_stream_urls_for_full_url_mode() { let urls = StreamCheckService::resolve_codex_stream_urls( diff --git a/src-tauri/src/services/webdav_auto_sync.rs b/src-tauri/src/services/webdav_auto_sync.rs index a2210eaab..5fe26e8c9 100644 --- a/src-tauri/src/services/webdav_auto_sync.rs +++ b/src-tauri/src/services/webdav_auto_sync.rs @@ -173,10 +173,7 @@ async fn run_worker_loop( let started_at = Instant::now(); let mut merged_count = 1usize; - loop { - let Some(wait_for) = auto_sync_wait_duration(started_at, Instant::now()) else { - break; - }; + while let Some(wait_for) = auto_sync_wait_duration(started_at, Instant::now()) { let timeout = tokio::time::timeout(wait_for, rx.recv()).await; match timeout { diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 0c9482a53..4a6fd8399 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -113,7 +113,7 @@ interface ClaudeFormFieldsProps { // Speed Test Endpoints speedTestEndpoints: EndpointCandidate[]; - // API Format (for third-party providers that use OpenAI Chat Completions format) + // API Format (for Claude-compatible providers that need request/response conversion) apiFormat: ClaudeApiFormat; onApiFormatChange: (format: ClaudeApiFormat) => void; @@ -436,7 +436,14 @@ export function ClaudeFormFields({ ? t("providerForm.apiHintResponses") : apiFormat === "openai_chat" ? t("providerForm.apiHintOAI") - : t("providerForm.apiHint") + : apiFormat === "gemini_native" + ? t("providerForm.apiHintGeminiNative") + : t("providerForm.apiHint") + } + fullUrlHint={ + apiFormat === "gemini_native" + ? t("providerForm.fullUrlHintGeminiNative") + : undefined } onManageClick={() => onEndpointModalToggle(true)} showFullUrlToggle={true} @@ -511,6 +518,11 @@ export function ClaudeFormFields({ defaultValue: "OpenAI Responses API (需转换)", })} + + {t("providerForm.apiFormatGeminiNative", { + defaultValue: "Gemini Native generateContent (需转换)", + })} +

diff --git a/src/components/providers/forms/shared/EndpointField.tsx b/src/components/providers/forms/shared/EndpointField.tsx index bf33afc92..4d5f53f1e 100644 --- a/src/components/providers/forms/shared/EndpointField.tsx +++ b/src/components/providers/forms/shared/EndpointField.tsx @@ -11,6 +11,7 @@ interface EndpointFieldProps { onChange: (value: string) => void; placeholder: string; hint?: string; + fullUrlHint?: string; showManageButton?: boolean; onManageClick?: () => void; manageButtonLabel?: string; @@ -26,6 +27,7 @@ export function EndpointField({ onChange, placeholder, hint, + fullUrlHint, showManageButton = true, onManageClick, manageButtonLabel, @@ -40,7 +42,8 @@ export function EndpointField({ }); const effectiveHint = showFullUrlToggle && isFullUrl - ? t("providerForm.fullUrlHint", { + ? fullUrlHint || + t("providerForm.fullUrlHint", { defaultValue: "💡 请填写完整请求 URL,并且必须开启代理后使用;代理将直接使用此 URL,不拼接路径", }) diff --git a/src/config/claudeProviderPresets.ts b/src/config/claudeProviderPresets.ts index 55408f56b..26c19a00e 100644 --- a/src/config/claudeProviderPresets.ts +++ b/src/config/claudeProviderPresets.ts @@ -49,7 +49,12 @@ export interface ProviderPreset { // - "anthropic" (默认): Anthropic Messages API 格式,直接透传 // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 // - "openai_responses": OpenAI Responses API 格式,需要格式转换 - apiFormat?: "anthropic" | "openai_chat" | "openai_responses"; + // - "gemini_native": Gemini Native generateContent API 格式,需要格式转换 + apiFormat?: + | "anthropic" + | "openai_chat" + | "openai_responses" + | "gemini_native"; // 供应商类型标识(用于特殊供应商检测) // - "github_copilot": GitHub Copilot 供应商(需要 OAuth 认证) @@ -80,6 +85,27 @@ export const providerPresets: ProviderPreset[] = [ icon: "anthropic", iconColor: "#D4915D", }, + { + name: "Gemini Native", + websiteUrl: "https://ai.google.dev/gemini-api", + apiKeyUrl: "https://aistudio.google.com/app/apikey", + apiKeyField: "ANTHROPIC_API_KEY", + settingsConfig: { + env: { + ANTHROPIC_BASE_URL: "https://generativelanguage.googleapis.com", + ANTHROPIC_API_KEY: "", + ANTHROPIC_MODEL: "gemini-2.5-pro", + ANTHROPIC_DEFAULT_HAIKU_MODEL: "gemini-2.5-flash", + ANTHROPIC_DEFAULT_SONNET_MODEL: "gemini-2.5-pro", + ANTHROPIC_DEFAULT_OPUS_MODEL: "gemini-2.5-pro", + }, + }, + category: "third_party", + apiFormat: "gemini_native", + endpointCandidates: ["https://generativelanguage.googleapis.com"], + icon: "gemini", + iconColor: "#4285F4", + }, { name: "Shengsuanyun", nameKey: "providerForm.presets.shengsuanyun", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index eb6cbaf00..0872a4e93 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -801,6 +801,7 @@ "modelHint": "💡 Leave blank to use provider's default model", "apiHint": "💡 Fill in Claude API compatible service endpoint, avoid trailing slash", "apiHintOAI": "💡 Fill in OpenAI Chat Completions compatible service endpoint, avoid trailing slash", + "apiHintGeminiNative": "💡 Prefer a Gemini Native base URL such as https://generativelanguage.googleapis.com or https://generativelanguage.googleapis.com/v1beta; the proxy will append models/*:generateContent automatically", "codexApiHint": "💡 Fill in service endpoint compatible with OpenAI Response format", "fillSupplierName": "Please fill in provider name", "fillConfigContent": "Please fill in configuration content", @@ -823,9 +824,11 @@ "fullUrlEnabled": "Full URL Mode", "fullUrlDisabled": "Mark as Full URL", "fullUrlHint": "💡 Enter the full request URL. This mode requires routing to be enabled, and routing will use the URL as-is without appending a path", + "fullUrlHintGeminiNative": "💡 In Gemini Native full URL mode, two inputs are supported: 1. official/structured Gemini URLs, which will still be normalized to the requested model and streaming method; 2. opaque custom relay URLs, which will be used mostly as-is with only query parameters appended", "apiFormatAnthropic": "Anthropic Messages (Native)", "apiFormatOpenAIChat": "OpenAI Chat Completions (Requires routing)", "apiFormatOpenAIResponses": "OpenAI Responses API (Requires routing)", + "apiFormatGeminiNative": "Gemini Native generateContent (Requires routing)", "authField": "Auth Field", "authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN (Default)", "authFieldApiKey": "ANTHROPIC_API_KEY", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c184c167d..d7299c95c 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -801,6 +801,7 @@ "modelHint": "💡 空欄ならプロバイダーのデフォルトモデルを使用します", "apiHint": "💡 Claude API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください", "apiHintOAI": "💡 OpenAI Chat Completions 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください", + "apiHintGeminiNative": "💡 Gemini Native では https://generativelanguage.googleapis.com または https://generativelanguage.googleapis.com/v1beta のような base URL を推奨します。プロキシが models/*:generateContent を自動補完します", "codexApiHint": "💡 OpenAI Response 互換のサービスエンドポイントを入力してください", "fillSupplierName": "プロバイダー名を入力してください", "fillConfigContent": "設定内容を入力してください", @@ -823,9 +824,11 @@ "fullUrlEnabled": "フル URL モード", "fullUrlDisabled": "フル URL として設定", "fullUrlHint": "💡 完全なリクエスト URL を入力してください。このモードはルーティングを有効にして使用する必要があり、ルーティングはこの URL をそのまま使用し、パスを追加しません", + "fullUrlHintGeminiNative": "💡 Gemini Native のフル URL モードでは 2 種類の入力を扱えます。1. 公式/構造化された Gemini URL は、要求されたモデルやストリーミング方式に合わせて正規化されます。2. カスタム relay の opaque な完全 URL は、主にそのまま使用され、必要なクエリだけ追加されます", "apiFormatAnthropic": "Anthropic Messages(ネイティブ)", "apiFormatOpenAIChat": "OpenAI Chat Completions(ルーティングが必要)", "apiFormatOpenAIResponses": "OpenAI Responses API(ルーティングが必要)", + "apiFormatGeminiNative": "Gemini Native generateContent(ルーティングが必要)", "authField": "認証フィールド", "authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(デフォルト)", "authFieldApiKey": "ANTHROPIC_API_KEY", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index faaeaad7f..93899f663 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -802,6 +802,7 @@ "modelHint": "💡 留空将使用供应商的默认模型", "apiHint": "💡 填写兼容 Claude API 的服务端点地址,不要以斜杠结尾", "apiHintOAI": "💡 填写兼容 OpenAI Chat Completions 的服务端点地址,不要以斜杠结尾", + "apiHintGeminiNative": "💡 建议填写 Gemini Native 的 base URL,例如 https://generativelanguage.googleapis.com 或 https://generativelanguage.googleapis.com/v1beta;代理会自动补全 models/*:generateContent", "codexApiHint": "💡 填写兼容 OpenAI Response 格式的服务端点地址", "fillSupplierName": "请填写供应商名称", "fillConfigContent": "请填写配置内容", @@ -824,9 +825,11 @@ "fullUrlEnabled": "完整 URL 模式", "fullUrlDisabled": "标记为完整 URL", "fullUrlHint": "💡 请填写完整请求 URL,并且必须开启路由后使用;路由将直接使用此 URL,不拼接路径", + "fullUrlHintGeminiNative": "💡 Gemini Native 下,完整 URL 模式同时兼容两类地址:1. 官方/标准 Gemini URL,代理会按模型和流式参数自动归一化;2. 自定义 relay 的完整 URL,代理会尽量原样使用,只补查询参数,不再强行追加 models 路径", "apiFormatAnthropic": "Anthropic Messages (原生)", "apiFormatOpenAIChat": "OpenAI Chat Completions (需开启路由)", "apiFormatOpenAIResponses": "OpenAI Responses API (需开启路由)", + "apiFormatGeminiNative": "Gemini Native generateContent (需开启路由)", "authField": "认证字段", "authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(默认)", "authFieldApiKey": "ANTHROPIC_API_KEY", diff --git a/src/types.ts b/src/types.ts index e5fbb7def..8379c217b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -141,7 +141,12 @@ export interface ProviderMeta { // - "anthropic": 原生 Anthropic Messages API 格式,直接透传 // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 // - "openai_responses": OpenAI Responses API 格式,需要格式转换 - apiFormat?: "anthropic" | "openai_chat" | "openai_responses"; + // - "gemini_native": Gemini Native generateContent API 格式,需要格式转换 + apiFormat?: + | "anthropic" + | "openai_chat" + | "openai_responses" + | "gemini_native"; // 通用认证绑定 authBinding?: AuthBinding; // Claude 认证字段名 @@ -166,7 +171,12 @@ export type SkillStorageLocation = "cc_switch" | "unified"; // - "anthropic": 原生 Anthropic Messages API 格式,直接透传 // - "openai_chat": OpenAI Chat Completions 格式,需要格式转换 // - "openai_responses": OpenAI Responses API 格式,需要格式转换 -export type ClaudeApiFormat = "anthropic" | "openai_chat" | "openai_responses"; +// - "gemini_native": Gemini Native generateContent API 格式,需要格式转换 +export type ClaudeApiFormat = + | "anthropic" + | "openai_chat" + | "openai_responses" + | "gemini_native"; // Claude 认证字段类型 export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY"; From 1126c7459dd7690e3704cc4374805d470824b5d5 Mon Sep 17 00:00:00 2001 From: Coconut-Fish <78026974+Coconut-Fish@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:48:58 +0800 Subject: [PATCH 5/9] Style/add provider.notes (#2138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style(FailoverQueueManager): 显示供应商备注信息 * style(FailoverQueueItem): 添加供应商备注字段以支持备注信息显示 * style(FailoverQueueManager): 显示供应商备注信息 * style(FailoverQueueItem): 添加供应商备注字段以支持备注信息显示 * style(FailoverQueueManager): 更新供应商备注信息的显示样式 * style(FailoverQueueItem): 添加条件序列化以优化供应商备注字段 --- src-tauri/src/database/dao/failover.rs | 5 ++++- src/components/proxy/FailoverQueueManager.tsx | 10 ++++++++++ src/types/proxy.ts | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/database/dao/failover.rs b/src-tauri/src/database/dao/failover.rs index 85f1c6e38..ac01fc54b 100644 --- a/src-tauri/src/database/dao/failover.rs +++ b/src-tauri/src/database/dao/failover.rs @@ -14,6 +14,8 @@ pub struct FailoverQueueItem { pub provider_id: String, pub provider_name: String, pub sort_index: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider_notes: Option, } impl Database { @@ -23,7 +25,7 @@ impl Database { let mut stmt = conn .prepare( - "SELECT id, name, sort_index + "SELECT id, name, sort_index, notes FROM providers WHERE app_type = ?1 AND in_failover_queue = 1 ORDER BY COALESCE(sort_index, 999999), id ASC", @@ -36,6 +38,7 @@ impl Database { provider_id: row.get(0)?, provider_name: row.get(1)?, sort_index: row.get(2)?, + provider_notes: row.get(3)?, }) }) .map_err(|e| AppError::Database(e.to_string()))? diff --git a/src/components/proxy/FailoverQueueManager.tsx b/src/components/proxy/FailoverQueueManager.tsx index 9d56475dc..417a61912 100644 --- a/src/components/proxy/FailoverQueueManager.tsx +++ b/src/components/proxy/FailoverQueueManager.tsx @@ -182,6 +182,11 @@ export function FailoverQueueManager({ {availableProviders?.map((provider) => ( {provider.name} + {provider.notes && ( + + ({provider.notes}) + + )} ))} {(!availableProviders || availableProviders.length === 0) && ( @@ -278,6 +283,11 @@ function QueueItem({

{item.providerName} + {item.providerNotes && ( + + ({item.providerNotes}) + + )}
diff --git a/src/types/proxy.ts b/src/types/proxy.ts index 7aa6f42de..29fac7fc5 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -109,6 +109,7 @@ export interface ProxyUsageRecord { export interface FailoverQueueItem { providerId: string; providerName: string; + providerNotes?: string; sortIndex?: number; } From 9871d3d1ebe698a5793ddbb94a941790f657a911 Mon Sep 17 00:00:00 2001 From: YaoguoHH Date: Sun, 19 Apr 2026 15:25:25 +0800 Subject: [PATCH 6/9] fix(skills): sync imported skills to app directories after import (#2101) `import_from_apps()` saves skills to the database but does not create symlinks/copies in the target app directories (e.g. `~/.claude/skills/`). This causes skills to appear as "installed" in the UI while the actual files are missing from the app directories. Add `sync_to_app_dir()` calls after `db.save_skill()` in the import loop, matching the pattern used by `install()` and `toggle_app()`. --- src-tauri/src/services/skill.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index bce8dd6e3..892c621b4 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -1549,6 +1549,20 @@ impl SkillService { // 保存到数据库 db.save_skill(&skill)?; + + // 同步到已启用的应用目录(创建 symlink 或复制文件) + for app in AppType::all() { + if skill.apps.is_enabled_for(&app) { + if let Err(e) = Self::sync_to_app_dir(&skill.directory, &app) { + log::warn!( + "导入后同步 Skill '{}' 到 {:?} 失败: {e:#}", + skill.directory, + app + ); + } + } + } + imported.push(skill); } From 87635e7fc666a05c143cd5309ab149a114b80ae3 Mon Sep 17 00:00:00 2001 From: hotelbe Date: Sun, 19 Apr 2026 20:29:46 +0800 Subject: [PATCH 7/9] feat(copilot): add GitHub Enterprise Server support (#2175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(copilot): add GitHub Enterprise Server support * fix(copilot): address GHES PR review findings (P1 + 2×P2) - P1: Use composite account ID (domain:user_id) for GHES to prevent cross-instance ID collisions; github.com keeps plain numeric ID for backward compatibilit - P2-a: Use get_api_endpoint() for model list URL with automatic fallback to static URL when dynamic endpoint resolution fails - P2-b: Add normalize_github_domain() as backend SSOT for domain normalization (lowercase, strip protocol/path/query, reject userinfo) Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- src-tauri/src/commands/auth.rs | 11 +- src-tauri/src/commands/copilot.rs | 15 +- .../src/proxy/providers/codex_oauth_auth.rs | 1 + src-tauri/src/proxy/providers/copilot_auth.rs | 382 +++++++++++++++--- .../providers/forms/CopilotAuthSection.tsx | 63 ++- .../providers/forms/hooks/useCopilotAuth.ts | 4 +- .../providers/forms/hooks/useManagedAuth.ts | 8 +- src/i18n/locales/en.json | 6 +- src/i18n/locales/ja.json | 6 +- src/i18n/locales/zh.json | 6 +- src/lib/api/auth.ts | 5 + src/lib/api/copilot.ts | 2 + 12 files changed, 441 insertions(+), 68 deletions(-) diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index e95c9b234..c3036023a 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -18,6 +18,7 @@ pub struct ManagedAuthAccount { pub avatar_url: Option, pub authenticated_at: i64, pub is_default: bool, + pub github_domain: String, } #[derive(Debug, Clone, serde::Serialize)] @@ -59,6 +60,7 @@ fn map_account( login: account.login, avatar_url: account.avatar_url, authenticated_at: account.authenticated_at, + github_domain: account.github_domain, } } @@ -79,6 +81,7 @@ fn map_device_code_response( #[tauri::command(rename_all = "camelCase")] pub async fn auth_start_login( auth_provider: String, + github_domain: Option, copilot_state: State<'_, CopilotAuthState>, codex_state: State<'_, CodexOAuthState>, ) -> Result { @@ -87,7 +90,7 @@ pub async fn auth_start_login( AUTH_PROVIDER_GITHUB_COPILOT => { let auth_manager = copilot_state.0.read().await; let response = auth_manager - .start_device_flow() + .start_device_flow(github_domain.as_deref()) .await .map_err(|e| e.to_string())?; Ok(map_device_code_response(auth_provider, response)) @@ -108,6 +111,7 @@ pub async fn auth_start_login( pub async fn auth_poll_for_account( auth_provider: String, device_code: String, + github_domain: Option, copilot_state: State<'_, CopilotAuthState>, codex_state: State<'_, CodexOAuthState>, ) -> Result, String> { @@ -115,7 +119,10 @@ pub async fn auth_poll_for_account( match auth_provider { AUTH_PROVIDER_GITHUB_COPILOT => { let auth_manager = copilot_state.0.write().await; - match auth_manager.poll_for_token(&device_code).await { + match auth_manager + .poll_for_token(&device_code, github_domain.as_deref()) + .await + { Ok(account) => { let default_account_id = auth_manager.get_status().await.default_account_id; Ok(account.map(|account| { diff --git a/src-tauri/src/commands/copilot.rs b/src-tauri/src/commands/copilot.rs index 7e36e8460..fb7104aa8 100644 --- a/src-tauri/src/commands/copilot.rs +++ b/src-tauri/src/commands/copilot.rs @@ -20,11 +20,12 @@ pub struct CopilotAuthState(pub Arc>); /// 返回设备码和用户码,用于 OAuth 认证 #[tauri::command] pub async fn copilot_start_device_flow( + github_domain: Option, state: State<'_, CopilotAuthState>, ) -> Result { let auth_manager = state.0.read().await; auth_manager - .start_device_flow() + .start_device_flow(github_domain.as_deref()) .await .map_err(|e| e.to_string()) } @@ -36,10 +37,14 @@ pub async fn copilot_start_device_flow( #[tauri::command(rename_all = "camelCase")] pub async fn copilot_poll_for_auth( device_code: String, + github_domain: Option, state: State<'_, CopilotAuthState>, ) -> Result { let auth_manager = state.0.write().await; - match auth_manager.poll_for_token(&device_code).await { + match auth_manager + .poll_for_token(&device_code, github_domain.as_deref()) + .await + { Ok(Some(_account)) => { log::info!("[CopilotAuth] 用户已授权"); Ok(true) @@ -61,10 +66,14 @@ pub async fn copilot_poll_for_auth( #[tauri::command(rename_all = "camelCase")] pub async fn copilot_poll_for_account( device_code: String, + github_domain: Option, state: State<'_, CopilotAuthState>, ) -> Result, String> { let auth_manager = state.0.write().await; - match auth_manager.poll_for_token(&device_code).await { + match auth_manager + .poll_for_token(&device_code, github_domain.as_deref()) + .await + { Ok(account) => Ok(account), Err(crate::proxy::providers::copilot_auth::CopilotAuthError::AuthorizationPending) => { Ok(None) diff --git a/src-tauri/src/proxy/providers/codex_oauth_auth.rs b/src-tauri/src/proxy/providers/codex_oauth_auth.rs index 508e7cd4a..945473f01 100644 --- a/src-tauri/src/proxy/providers/codex_oauth_auth.rs +++ b/src-tauri/src/proxy/providers/codex_oauth_auth.rs @@ -203,6 +203,7 @@ impl From<&CodexAccountData> for GitHubAccount { .unwrap_or_else(|| format!("ChatGPT ({})", &data.account_id)), avatar_url: None, authenticated_at: data.authenticated_at, + github_domain: "github.com".to_string(), } } } diff --git a/src-tauri/src/proxy/providers/copilot_auth.rs b/src-tauri/src/proxy/providers/copilot_auth.rs index 44a232712..c82011fc6 100644 --- a/src-tauri/src/proxy/providers/copilot_auth.rs +++ b/src-tauri/src/proxy/providers/copilot_auth.rs @@ -24,26 +24,114 @@ use std::path::PathBuf; use std::sync::Arc; use tokio::sync::{Mutex, RwLock}; -/// GitHub OAuth 客户端 ID(VS Code 使用的 ID) +/// GitHub OAuth 客户端 ID(VS Code)- 用于 github.com const GITHUB_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98"; +/// GitHub OAuth 客户端 ID(与 OpenCode 相同)- 在所有 GHES Copilot 实例上预注册 +const GITHUB_CLIENT_ID_GHES: &str = "Ov23li8tweQw6odWQebz"; + +/// 默认 GitHub 域名 +const DEFAULT_GITHUB_DOMAIN: &str = "github.com"; + +/// 根据域名选择 OAuth 客户端 ID +fn github_client_id(domain: &str) -> &'static str { + if domain == DEFAULT_GITHUB_DOMAIN { + GITHUB_CLIENT_ID + } else { + GITHUB_CLIENT_ID_GHES + } +} + +fn default_github_domain() -> String { + DEFAULT_GITHUB_DOMAIN.to_string() +} + /// GitHub 设备码 URL -const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code"; +fn github_device_code_url(domain: &str) -> String { + format!("https://{domain}/login/device/code") +} /// GitHub OAuth Token URL -const GITHUB_OAUTH_TOKEN_URL: &str = "https://github.com/login/oauth/access_token"; +fn github_oauth_token_url(domain: &str) -> String { + format!("https://{domain}/login/oauth/access_token") +} + +/// GitHub API 基础 URL(github.com 用 api.github.com,GHES 用 {domain}/api/v3) +fn github_api_base(domain: &str) -> String { + if domain == DEFAULT_GITHUB_DOMAIN { + "https://api.github.com".to_string() + } else { + format!("https://{domain}/api/v3") + } +} /// Copilot Token URL -const COPILOT_TOKEN_URL: &str = "https://api.github.com/copilot_internal/v2/token"; +fn copilot_token_url(domain: &str) -> String { + format!("{}/copilot_internal/v2/token", github_api_base(domain)) +} /// GitHub User API URL -const GITHUB_USER_URL: &str = "https://api.github.com/user"; +fn github_user_url(domain: &str) -> String { + format!("{}/user", github_api_base(domain)) +} + +/// Copilot 使用量 API URL +fn copilot_usage_url(domain: &str) -> String { + format!("{}/copilot_internal/user", github_api_base(domain)) +} + +/// Copilot API 基础地址(github.com 用 api.githubcopilot.com,GHES 用 copilot-api.{domain}) +fn copilot_api_base(domain: &str) -> String { + if domain == DEFAULT_GITHUB_DOMAIN { + "https://api.githubcopilot.com".to_string() + } else { + format!("https://copilot-api.{domain}") + } +} /// Token 刷新提前量(秒) const TOKEN_REFRESH_BUFFER_SECONDS: i64 = 60; -/// Copilot API 端点 -const COPILOT_MODELS_URL: &str = "https://api.githubcopilot.com/models"; +/// 判断是否为 GitHub Enterprise Server(非 github.com) +fn is_ghes(domain: &str) -> bool { + domain != DEFAULT_GITHUB_DOMAIN +} + +/// 归一化 GitHub 域名(SSOT): +/// - 小写化 +/// - 剥离协议(https:// http://) +/// - 剥离尾斜杠、path、query、fragment +/// - 拒绝包含 userinfo(@)的输入 +/// - 保留端口号(如有) +fn normalize_github_domain(raw: &str) -> Result { + let s = raw.trim(); + // 剥离协议 + let s = s + .strip_prefix("https://") + .or_else(|| s.strip_prefix("http://")) + .unwrap_or(s); + // 取 host 部分(到第一个 / 或 ? 或 #) + let host = s.split(&['/', '?', '#'][..]).next().unwrap_or(s); + // 拒绝 userinfo + if host.contains('@') { + return Err(CopilotAuthError::InvalidDomain(raw.to_string())); + } + let normalized = host.to_lowercase(); + if normalized.is_empty() { + return Err(CopilotAuthError::InvalidDomain(raw.to_string())); + } + Ok(normalized) +} + +/// 生成复合账号 ID,确保不同 GHES 实例的 user ID 不会冲突。 +/// github.com 账号保持原格式(向后兼容),GHES 账号使用 `domain:user_id` 格式。 +fn composite_account_id(domain: &str, user_id: u64) -> String { + if domain == DEFAULT_GITHUB_DOMAIN { + user_id.to_string() + } else { + format!("{}:{}", domain, user_id) + } +} /// Copilot API Header 常量 pub const COPILOT_EDITOR_VERSION: &str = "vscode/1.110.1"; @@ -52,12 +140,6 @@ pub const COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.38.2"; pub const COPILOT_API_VERSION: &str = "2025-10-01"; pub const COPILOT_INTEGRATION_ID: &str = "vscode-chat"; -/// Copilot 使用量 API URL -const COPILOT_USAGE_URL: &str = "https://api.github.com/copilot_internal/user"; - -/// 默认 Copilot API 端点 -const DEFAULT_COPILOT_API_ENDPOINT: &str = "https://api.githubcopilot.com"; - /// Copilot 使用量响应 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CopilotUsageResponse { @@ -169,6 +251,9 @@ pub enum CopilotAuthError { #[error("账号不存在: {0}")] AccountNotFound(String), + + #[error("无效的 GitHub 域名: {0}")] + InvalidDomain(String), } impl From for CopilotAuthError { @@ -253,15 +338,19 @@ pub struct GitHubAccount { pub avatar_url: Option, /// 认证时间戳 pub authenticated_at: i64, + /// GitHub 域名(github.com 或 GHES 域名) + #[serde(default = "default_github_domain")] + pub github_domain: String, } impl From<&GitHubAccountData> for GitHubAccount { fn from(data: &GitHubAccountData) -> Self { GitHubAccount { - id: data.user.id.to_string(), + id: composite_account_id(&data.github_domain, data.user.id), login: data.user.login.clone(), avatar_url: data.user.avatar_url.clone(), authenticated_at: data.authenticated_at, + github_domain: data.github_domain.clone(), } } } @@ -295,6 +384,9 @@ struct GitHubAccountData { pub user: GitHubUser, /// 认证时间戳 pub authenticated_at: i64, + /// GitHub 域名(github.com 或 GHES 域名) + #[serde(default = "default_github_domain")] + pub github_domain: String, } /// 持久化存储结构(v3 多账号 + 默认账号格式) @@ -437,14 +529,16 @@ impl CopilotAuthManager { &self, github_token: String, user: GitHubUser, + github_domain: String, ) -> Result { - let account_id = user.id.to_string(); + let account_id = composite_account_id(&github_domain, user.id); let now = chrono::Utc::now().timestamp(); let account_data = GitHubAccountData { github_token, user: user.clone(), authenticated_at: now, + github_domain: github_domain.clone(), }; let account = GitHubAccount { @@ -452,6 +546,7 @@ impl CopilotAuthManager { login: user.login.clone(), avatar_url: user.avatar_url.clone(), authenticated_at: now, + github_domain, }; { @@ -497,15 +592,25 @@ impl CopilotAuthManager { // ==================== 设备码流程 ==================== /// 启动设备码流程 - pub async fn start_device_flow(&self) -> Result { - log::info!("[CopilotAuth] 启动设备码流程"); + pub async fn start_device_flow( + &self, + github_domain: Option<&str>, + ) -> Result { + let domain = match github_domain { + Some(d) => normalize_github_domain(d)?, + None => DEFAULT_GITHUB_DOMAIN.to_string(), + }; + log::info!("[CopilotAuth] 启动设备码流程 (domain: {domain})"); let response = self .http_client - .post(GITHUB_DEVICE_CODE_URL) + .post(github_device_code_url(&domain)) .header("Accept", "application/json") .header("User-Agent", COPILOT_USER_AGENT) - .form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "read:user")]) + .form(&[ + ("client_id", github_client_id(&domain)), + ("scope", "read:user"), + ]) .send() .await?; @@ -534,16 +639,21 @@ impl CopilotAuthManager { pub async fn poll_for_token( &self, device_code: &str, + github_domain: Option<&str>, ) -> Result, CopilotAuthError> { - log::debug!("[CopilotAuth] 轮询 OAuth Token"); + let domain = match github_domain { + Some(d) => normalize_github_domain(d)?, + None => DEFAULT_GITHUB_DOMAIN.to_string(), + }; + log::debug!("[CopilotAuth] 轮询 OAuth Token (domain: {domain})"); let response = self .http_client - .post(GITHUB_OAUTH_TOKEN_URL) + .post(github_oauth_token_url(&domain)) .header("Accept", "application/json") .header("User-Agent", COPILOT_USER_AGENT) .form(&[ - ("client_id", GITHUB_CLIENT_ID), + ("client_id", github_client_id(&domain)), ("device_code", device_code), ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), ]) @@ -578,14 +688,28 @@ impl CopilotAuthManager { log::info!("[CopilotAuth] OAuth Token 获取成功"); // 获取用户信息 - let user = self.fetch_user_info_with_token(&access_token).await?; - - // 验证 Copilot 订阅(获取 Copilot Token) - self.fetch_copilot_token_with_github_token(&access_token, &user.id.to_string()) + let user = self + .fetch_user_info_with_token(&access_token, &domain) .await?; + // GHES 无需换取 Copilot Token,直接使用 OAuth token 作为 Bearer + // 参考 OpenCode 的实现:GHE Copilot 直接用 OAuth token 调用 copilot-api.{domain} + if !is_ghes(&domain) { + // github.com:验证 Copilot 订阅(获取 Copilot Token) + self.fetch_copilot_token_with_github_token( + &access_token, + &user.id.to_string(), + &domain, + ) + .await?; + } else { + log::info!("[CopilotAuth] GHES 账号,跳过 Copilot Token 兑换,直接使用 OAuth token"); + } + // 添加账号 - let account = self.add_account_internal(access_token, user).await?; + let account = self + .add_account_internal(access_token, user, domain) + .await?; Ok(Some(account)) } @@ -600,6 +724,16 @@ impl CopilotAuthManager { // 确保迁移完成 self.ensure_migration_complete().await?; + // GHES 账号直接使用 GitHub OAuth token,无需 Copilot token 交换 + let domain = self.get_account_domain(account_id).await; + if is_ghes(&domain) { + let accounts = self.accounts.read().await; + return accounts + .get(account_id) + .map(|a| a.github_token.clone()) + .ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string())); + } + // 检查缓存的 token { let tokens = self.copilot_tokens.read().await; @@ -627,16 +761,16 @@ impl CopilotAuthManager { } // 获取账号的 GitHub token - let github_token = { + let (github_token, domain) = { let accounts = self.accounts.read().await; - accounts + let account = accounts .get(account_id) - .map(|a| a.github_token.clone()) - .ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))? + .ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?; + (account.github_token.clone(), account.github_domain.clone()) }; // 刷新 Copilot token - self.fetch_copilot_token_with_github_token(&github_token, account_id) + self.fetch_copilot_token_with_github_token(&github_token, account_id, &domain) .await?; // 返回新 token @@ -687,11 +821,19 @@ impl CopilotAuthManager { ) -> Result, CopilotAuthError> { let copilot_token = self.get_valid_token_for_account(account_id).await?; + // 使用 get_api_endpoint() 动态解析 Copilot API 基础 URL。 + // 对于 github.com 账号,会查询 /copilot_internal/user 获取 endpoints.api 字段。 + // 对于 GHES 账号,/copilot_internal/user 可能不返回 endpoints——此时 + // get_api_endpoint() 会回退到 copilot_api_base(&domain),与之前的静态 URL + // 拼接结果一致。该回退行为是安全且符合预期的。 + let api_base = self.get_api_endpoint(account_id).await; + let models_url = format!("{}/models", api_base); + log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 可用模型"); let response = self .http_client - .get(COPILOT_MODELS_URL) + .get(&models_url) .header("Authorization", format!("Bearer {copilot_token}")) .header("Content-Type", "application/json") .header("copilot-integration-id", "vscode-chat") @@ -767,19 +909,19 @@ impl CopilotAuthManager { &self, account_id: &str, ) -> Result { - let github_token = { + let (github_token, domain) = { let accounts = self.accounts.read().await; - accounts + let account = accounts .get(account_id) - .map(|a| a.github_token.clone()) - .ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))? + .ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?; + (account.github_token.clone(), account.github_domain.clone()) }; log::info!("[CopilotAuth] 获取账号 {account_id} 的 Copilot 使用量"); let response = self .http_client - .get(COPILOT_USAGE_URL) + .get(copilot_usage_url(&domain)) .header("Authorization", format!("token {github_token}")) .header("Content-Type", "application/json") .header("editor-version", COPILOT_EDITOR_VERSION) @@ -862,7 +1004,8 @@ impl CopilotAuthManager { log::debug!( "[CopilotAuth] 获取账号 {account_id} 动态 API 端点失败: {e},使用默认值" ); - DEFAULT_COPILOT_API_ENDPOINT.to_string() + let domain = self.get_account_domain(account_id).await; + copilot_api_base(&domain) } } } @@ -873,24 +1016,27 @@ impl CopilotAuthManager { match self.resolve_default_account_id().await { Some(id) => self.get_api_endpoint(&id).await, - None => DEFAULT_COPILOT_API_ENDPOINT.to_string(), + None => { + // 无账号时回退到 github.com 的默认端点 + copilot_api_base(DEFAULT_GITHUB_DOMAIN) + } } } async fn fetch_and_cache_endpoint(&self, account_id: &str) -> Result { - let github_token = { + let (github_token, domain) = { let accounts = self.accounts.read().await; - accounts + let account = accounts .get(account_id) - .map(|a| a.github_token.clone()) - .ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))? + .ok_or_else(|| CopilotAuthError::AccountNotFound(account_id.to_string()))?; + (account.github_token.clone(), account.github_domain.clone()) }; log::debug!("[CopilotAuth] 为账号 {account_id} 惰性拉取动态 API 端点"); let response = self .http_client - .get(COPILOT_USAGE_URL) + .get(copilot_usage_url(&domain)) .header("Authorization", format!("token {github_token}")) .header("Content-Type", "application/json") .header("editor-version", COPILOT_EDITOR_VERSION) @@ -918,7 +1064,7 @@ impl CopilotAuthManager { let endpoint = match usage.endpoints { Some(endpoints) => endpoints.api.clone(), - None => DEFAULT_COPILOT_API_ENDPOINT.to_string(), + None => copilot_api_base(&domain), }; // 缓存端点(包括默认值),避免重复请求 @@ -1075,6 +1221,15 @@ impl CopilotAuthManager { Self::fallback_default_account_id(&accounts) } + /// 获取指定账号的 GitHub 域名 + async fn get_account_domain(&self, account_id: &str) -> String { + let accounts = self.accounts.read().await; + accounts + .get(account_id) + .map(|a| a.github_domain.clone()) + .unwrap_or_else(|| DEFAULT_GITHUB_DOMAIN.to_string()) + } + async fn get_refresh_lock(&self, account_id: &str) -> Arc> { { let refresh_locks = self.refresh_locks.read().await; @@ -1155,10 +1310,11 @@ impl CopilotAuthManager { async fn fetch_user_info_with_token( &self, github_token: &str, + domain: &str, ) -> Result { let response = self .http_client - .get(GITHUB_USER_URL) + .get(github_user_url(domain)) .header("Authorization", format!("token {github_token}")) .header("User-Agent", COPILOT_USER_AGENT) .header("Editor-Version", COPILOT_EDITOR_VERSION) @@ -1185,12 +1341,13 @@ impl CopilotAuthManager { &self, github_token: &str, account_id: &str, + domain: &str, ) -> Result<(), CopilotAuthError> { - log::debug!("[CopilotAuth] 获取账号 {account_id} 的 Copilot Token"); + log::debug!("[CopilotAuth] 获取账号 {account_id} 的 Copilot Token (domain: {domain})"); let response = self .http_client - .get(COPILOT_TOKEN_URL) + .get(copilot_token_url(domain)) .header("Authorization", format!("token {github_token}")) .header("User-Agent", COPILOT_USER_AGENT) .header("Editor-Version", COPILOT_EDITOR_VERSION) @@ -1284,20 +1441,32 @@ impl CopilotAuthManager { log::info!("[CopilotAuth] 执行旧格式迁移"); // 获取用户信息 - match self.fetch_user_info_with_token(&legacy_token).await { + match self + .fetch_user_info_with_token(&legacy_token, DEFAULT_GITHUB_DOMAIN) + .await + { Ok(user) => { - let account_id = user.id.to_string(); + let account_id = composite_account_id(DEFAULT_GITHUB_DOMAIN, user.id); // 尝试获取 Copilot token 验证订阅 if let Err(e) = self - .fetch_copilot_token_with_github_token(&legacy_token, &account_id) + .fetch_copilot_token_with_github_token( + &legacy_token, + &account_id, + DEFAULT_GITHUB_DOMAIN, + ) .await { log::warn!("[CopilotAuth] 迁移时验证 Copilot 订阅失败: {e}"); } // 添加账号 - self.add_account_internal(legacy_token, user).await?; + self.add_account_internal( + legacy_token, + user, + DEFAULT_GITHUB_DOMAIN.to_string(), + ) + .await?; self.set_migration_error(None).await; log::info!("[CopilotAuth] 旧格式迁移完成"); @@ -1387,6 +1556,7 @@ mod tests { login: "testuser".to_string(), avatar_url: Some("https://example.com/avatar.png".to_string()), authenticated_at: 1234567890, + github_domain: DEFAULT_GITHUB_DOMAIN.to_string(), }], default_account_id: Some("12345".to_string()), migration_error: None, @@ -1420,6 +1590,7 @@ mod tests { avatar_url: Some("https://example.com/alice.png".to_string()), }, authenticated_at: 1700000000, + github_domain: DEFAULT_GITHUB_DOMAIN.to_string(), }, ); accounts.insert( @@ -1432,6 +1603,7 @@ mod tests { avatar_url: None, }, authenticated_at: 1700000001, + github_domain: DEFAULT_GITHUB_DOMAIN.to_string(), }, ); @@ -1479,6 +1651,7 @@ mod tests { avatar_url: Some("https://example.com/avatar.png".to_string()), }, authenticated_at: 1700000000, + github_domain: DEFAULT_GITHUB_DOMAIN.to_string(), }; let account = GitHubAccount::from(&data); @@ -1504,6 +1677,7 @@ mod tests { avatar_url: None, }, authenticated_at: 1700000000, + github_domain: DEFAULT_GITHUB_DOMAIN.to_string(), }, ); accounts.insert( @@ -1516,6 +1690,7 @@ mod tests { avatar_url: None, }, authenticated_at: 1700000001, + github_domain: DEFAULT_GITHUB_DOMAIN.to_string(), }, ); @@ -1546,6 +1721,7 @@ mod tests { avatar_url: None, }, authenticated_at: 1700000000, + github_domain: DEFAULT_GITHUB_DOMAIN.to_string(), }, ); } @@ -1630,6 +1806,7 @@ mod tests { avatar_url: None, }, authenticated_at: 1700000000, + github_domain: DEFAULT_GITHUB_DOMAIN.to_string(), }, ); } @@ -1664,6 +1841,7 @@ mod tests { avatar_url: None, }, authenticated_at: 1700000000, + github_domain: DEFAULT_GITHUB_DOMAIN.to_string(), }, ); } @@ -1746,6 +1924,7 @@ mod tests { avatar_url: None, }, authenticated_at: 1700000000, + github_domain: DEFAULT_GITHUB_DOMAIN.to_string(), }, ); } @@ -1801,7 +1980,7 @@ mod tests { let manager = CopilotAuthManager::new(temp_dir.path().to_path_buf()); let endpoint = manager.get_api_endpoint("12345").await; - assert_eq!(endpoint, DEFAULT_COPILOT_API_ENDPOINT); + assert_eq!(endpoint, copilot_api_base(DEFAULT_GITHUB_DOMAIN)); } #[tokio::test] @@ -1817,4 +1996,99 @@ mod tests { other => panic!("期望 AccountNotFound 错误,实际: {other:?}"), } } + + #[test] + fn test_normalize_github_domain() { + // 基本用法 + assert_eq!(normalize_github_domain("github.com").unwrap(), "github.com"); + assert_eq!( + normalize_github_domain("company.ghe.com").unwrap(), + "company.ghe.com" + ); + + // 剥离协议 + assert_eq!( + normalize_github_domain("https://company.ghe.com").unwrap(), + "company.ghe.com" + ); + assert_eq!( + normalize_github_domain("http://company.ghe.com").unwrap(), + "company.ghe.com" + ); + + // 小写化 + assert_eq!(normalize_github_domain("GitHub.COM").unwrap(), "github.com"); + assert_eq!( + normalize_github_domain("Company.GHE.Com").unwrap(), + "company.ghe.com" + ); + + // 剥离尾斜杠和 path + assert_eq!( + normalize_github_domain("company.ghe.com/").unwrap(), + "company.ghe.com" + ); + assert_eq!( + normalize_github_domain("company.ghe.com/api/v3").unwrap(), + "company.ghe.com" + ); + + // 剥离 query 和 fragment + assert_eq!( + normalize_github_domain("company.ghe.com?foo=bar").unwrap(), + "company.ghe.com" + ); + assert_eq!( + normalize_github_domain("company.ghe.com#section").unwrap(), + "company.ghe.com" + ); + + // 保留端口 + assert_eq!( + normalize_github_domain("company.ghe.com:8443").unwrap(), + "company.ghe.com:8443" + ); + + // 拒绝 userinfo + assert!(normalize_github_domain("user@company.ghe.com").is_err()); + + // 拒绝空输入 + assert!(normalize_github_domain("").is_err()); + assert!(normalize_github_domain(" ").is_err()); + } + + #[test] + fn test_composite_account_id() { + // github.com 保持原格式(向后兼容) + assert_eq!(composite_account_id("github.com", 12345), "12345"); + + // GHES 使用复合格式 + assert_eq!( + composite_account_id("company.ghe.com", 12345), + "company.ghe.com:12345" + ); + + // 不同 GHES 实例,相同 user ID,不冲突 + assert_ne!( + composite_account_id("a.ghe.com", 1), + composite_account_id("b.ghe.com", 1) + ); + } + + #[test] + fn test_github_account_from_data_ghes_uses_composite_id() { + let data = GitHubAccountData { + github_token: "gho_test".to_string(), + user: GitHubUser { + login: "testuser".to_string(), + id: 99999, + avatar_url: None, + }, + authenticated_at: 1700000000, + github_domain: "company.ghe.com".to_string(), + }; + + let account = GitHubAccount::from(&data); + assert_eq!(account.id, "company.ghe.com:99999"); + } } diff --git a/src/components/providers/forms/CopilotAuthSection.tsx b/src/components/providers/forms/CopilotAuthSection.tsx index 608eb6d0f..c328c3fc0 100644 --- a/src/components/providers/forms/CopilotAuthSection.tsx +++ b/src/components/providers/forms/CopilotAuthSection.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -45,6 +46,19 @@ export const CopilotAuthSection: React.FC = ({ }) => { const { t } = useTranslation(); const [copied, setCopied] = React.useState(false); + const [deploymentType, setDeploymentType] = React.useState< + "github.com" | "enterprise" + >("github.com"); + const [enterpriseDomain, setEnterpriseDomain] = React.useState(""); + + // 根据部署类型计算实际的 GitHub 域名 + const effectiveGithubDomain = + deploymentType === "enterprise" && enterpriseDomain.trim() + ? enterpriseDomain + .trim() + .replace(/^https?:\/\//, "") + .replace(/\/$/, "") + : undefined; const { accounts, @@ -63,7 +77,7 @@ export const CopilotAuthSection: React.FC = ({ setDefaultAccount, cancelAuth, logout, - } = useCopilotAuth(); + } = useCopilotAuth(effectiveGithubDomain); // 复制用户码 const copyUserCode = async () => { @@ -113,6 +127,41 @@ export const CopilotAuthSection: React.FC = ({
+ {/* GitHub 部署类型选择 */} +
+ + + {deploymentType === "enterprise" && ( + setEnterpriseDomain(e.target.value)} + /> + )} +
+ {migrationError && (

{t("copilot.migrationFailed", { @@ -179,6 +228,12 @@ export const CopilotAuthSection: React.FC = ({ {t("copilot.defaultAccount", "默认")} )} + {account.github_domain && + account.github_domain !== "github.com" && ( + + {account.github_domain} + + )} {selectedAccountId === account.id && ( {t("copilot.selected", "已选中")} @@ -223,6 +278,7 @@ export const CopilotAuthSection: React.FC = ({ onClick={addAccount} className="w-full" variant="outline" + disabled={deploymentType === "enterprise" && !enterpriseDomain.trim()} > {t("copilot.loginWithGitHub", "使用 GitHub 登录")} @@ -236,7 +292,10 @@ export const CopilotAuthSection: React.FC = ({ onClick={addAccount} className="w-full" variant="outline" - disabled={isAddingAccount} + disabled={ + isAddingAccount || + (deploymentType === "enterprise" && !enterpriseDomain.trim()) + } > {t("copilot.addAnotherAccount", "添加其他账号")} diff --git a/src/components/providers/forms/hooks/useCopilotAuth.ts b/src/components/providers/forms/hooks/useCopilotAuth.ts index 7b2c45010..8600d7d65 100644 --- a/src/components/providers/forms/hooks/useCopilotAuth.ts +++ b/src/components/providers/forms/hooks/useCopilotAuth.ts @@ -1,8 +1,8 @@ import type { GitHubAccount } from "@/lib/api"; import { useManagedAuth } from "./useManagedAuth"; -export function useCopilotAuth() { - const managedAuth = useManagedAuth("github_copilot"); +export function useCopilotAuth(githubDomain?: string) { + const managedAuth = useManagedAuth("github_copilot", githubDomain); const defaultAccount = managedAuth.accounts.find( (account) => account.id === managedAuth.defaultAccountId, diff --git a/src/components/providers/forms/hooks/useManagedAuth.ts b/src/components/providers/forms/hooks/useManagedAuth.ts index 138f3f726..86360b397 100644 --- a/src/components/providers/forms/hooks/useManagedAuth.ts +++ b/src/components/providers/forms/hooks/useManagedAuth.ts @@ -10,7 +10,10 @@ import type { type PollingState = "idle" | "polling" | "success" | "error"; -export function useManagedAuth(authProvider: ManagedAuthProvider) { +export function useManagedAuth( + authProvider: ManagedAuthProvider, + githubDomain?: string, +) { const queryClient = useQueryClient(); const queryKey = ["managed-auth-status", authProvider]; @@ -52,7 +55,7 @@ export function useManagedAuth(authProvider: ManagedAuthProvider) { }, [stopPolling]); const startLoginMutation = useMutation({ - mutationFn: () => authApi.authStartLogin(authProvider), + mutationFn: () => authApi.authStartLogin(authProvider, githubDomain), onSuccess: async (response) => { setDeviceCode(response); setPollingState("polling"); @@ -87,6 +90,7 @@ export function useManagedAuth(authProvider: ManagedAuthProvider) { const newAccount = await authApi.authPollForAccount( authProvider, response.device_code, + githubDomain, ); if (newAccount) { stopPolling(); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0872a4e93..b7e4aae87 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -889,7 +889,11 @@ "retry": "Retry", "copyCode": "Copy code", "migrationFailed": "Legacy auth migration failed: {{error}}", - "loadModelsFailed": "Failed to load Copilot models" + "loadModelsFailed": "Failed to load Copilot models", + "deploymentType": "GitHub Deployment Type", + "deploymentGitHubCom": "GitHub.com", + "deploymentEnterprise": "GitHub Enterprise Server", + "enterpriseDomainPlaceholder": "e.g. company.ghe.com" }, "codexOauth": { "authStatus": "Auth status", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d7299c95c..c76362a6d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -889,7 +889,11 @@ "retry": "再試行", "copyCode": "コードをコピー", "migrationFailed": "旧認証データの移行に失敗しました: {{error}}", - "loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました" + "loadModelsFailed": "Copilot モデル一覧の読み込みに失敗しました", + "deploymentType": "GitHub デプロイメントタイプ", + "deploymentGitHubCom": "GitHub.com", + "deploymentEnterprise": "GitHub Enterprise Server", + "enterpriseDomainPlaceholder": "例: company.ghe.com" }, "codexOauth": { "authStatus": "認証状態", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 93899f663..f3cbbeb17 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -890,7 +890,11 @@ "retry": "重试", "copyCode": "复制代码", "migrationFailed": "旧认证数据迁移失败:{{error}}", - "loadModelsFailed": "加载 Copilot 模型列表失败" + "loadModelsFailed": "加载 Copilot 模型列表失败", + "deploymentType": "GitHub 部署类型", + "deploymentGitHubCom": "GitHub.com", + "deploymentEnterprise": "GitHub Enterprise Server", + "enterpriseDomainPlaceholder": "例如:company.ghe.com" }, "codexOauth": { "authStatus": "认证状态", diff --git a/src/lib/api/auth.ts b/src/lib/api/auth.ts index a4d840ffa..294661802 100644 --- a/src/lib/api/auth.ts +++ b/src/lib/api/auth.ts @@ -9,6 +9,7 @@ export interface ManagedAuthAccount { avatar_url: string | null; authenticated_at: number; is_default: boolean; + github_domain: string; } export interface ManagedAuthStatus { @@ -30,19 +31,23 @@ export interface ManagedAuthDeviceCodeResponse { export async function authStartLogin( authProvider: ManagedAuthProvider, + githubDomain?: string, ): Promise { return invoke("auth_start_login", { authProvider, + githubDomain: githubDomain || null, }); } export async function authPollForAccount( authProvider: ManagedAuthProvider, deviceCode: string, + githubDomain?: string, ): Promise { return invoke("auth_poll_for_account", { authProvider, deviceCode, + githubDomain: githubDomain || null, }); } diff --git a/src/lib/api/copilot.ts b/src/lib/api/copilot.ts index 09eb55b00..39089574a 100644 --- a/src/lib/api/copilot.ts +++ b/src/lib/api/copilot.ts @@ -30,6 +30,8 @@ export interface GitHubAccount { avatar_url: string | null; /** 认证时间戳(Unix 秒) */ authenticated_at: number; + /** GitHub 域名(github.com 或 GHES 域名) */ + github_domain: string; } /** From 8ba92b5470de7fe2a38da57e10293db72c51b39f Mon Sep 17 00:00:00 2001 From: King Date: Sun, 19 Apr 2026 21:42:17 +0800 Subject: [PATCH 8/9] feat(ui): add quick-set button for model mapping fields (#2179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add quick-set button for model mapping fields Add a "一键设置" (Quick Set) button in the model mapping section to simplify provider configuration. When users enter a model name in any of the five model fields, they can now click this button to populate all fields with that same value. This addresses the UX friction of manually filling all five model mapping fields (主模型, 推理模型, Haiku, Sonnet, Opus) when the provider uses the same model name across all request types. Implementation: - Add Wand2 icon import from lucide-react - Insert quick-set button alongside existing fetch-models button - Logic picks first non-empty model value and applies to all fields - Show success toast after applying - Disabled state when all model fields are empty - Add i18n strings for zh, en, ja locales Relates to user feedback about tedious model configuration workflow. * style(ui): format ClaudeFormFields component code Apply consistent code formatting to ClaudeFormFields.tsx following project linting rules. Includes multi-line import statements and improved readability for conditional expressions. --- .../providers/forms/ClaudeFormFields.tsx | 69 ++++++++++++++++--- src/i18n/locales/en.json | 2 + src/i18n/locales/ja.json | 2 + src/i18n/locales/zh.json | 2 + 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 4a6fd8399..8362015f4 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -24,7 +24,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { ChevronDown, ChevronRight, Download, Loader2 } from "lucide-react"; +import { + ChevronDown, + ChevronRight, + Download, + Loader2, + Wand2, +} from "lucide-react"; import EndpointSpeedTest from "./EndpointSpeedTest"; import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared"; import { CopilotAuthSection } from "./CopilotAuthSection"; @@ -571,23 +577,64 @@ export function ClaudeFormFields({

{t("providerForm.modelMappingLabel")} - {!isCopilotPreset && ( +
+ {/* 一键设置按钮 */} - )} + {!isCopilotPreset && ( + + )} +

{t("providerForm.modelMappingHint")} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index b7e4aae87..684f1cc92 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -843,6 +843,8 @@ "modelHelper": "Optional: Specify default Claude model to use, leave blank to use system default.", "modelMappingLabel": "Model Mapping", "modelMappingHint": "Usually not needed if the provider natively serves Claude models. Only configure when you need to map requests to different model names.", + "quickSetModels": "Quick Set", + "quickSetSuccess": "Model name applied to all fields", "advancedOptionsToggle": "Advanced Options", "advancedOptionsHint": "Includes API format, auth field, and model mapping. Defaults work for most use cases.", "categoryOfficial": "Official", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c76362a6d..6d597cd0e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -843,6 +843,8 @@ "modelHelper": "任意: 既定で使いたい Claude モデルを指定。空欄ならシステム既定を使用します。", "modelMappingLabel": "モデルマッピング", "modelMappingHint": "プロバイダーが Claude モデルをネイティブ提供している場合、通常は設定不要です。リクエストを別のモデル名にマッピングする場合のみ設定してください。", + "quickSetModels": "一括設定", + "quickSetSuccess": "モデル名をすべてのフィールドに適用しました", "advancedOptionsToggle": "高級オプション", "advancedOptionsHint": "API フォーマット、認証フィールド、モデルマッピングの設定を含みます。通常はデフォルトのままで問題ありません。", "categoryOfficial": "公式", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index f3cbbeb17..47dabcfbe 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -844,6 +844,8 @@ "modelHelper": "可选:指定默认使用的 Claude 模型,留空则使用系统默认。", "modelMappingLabel": "模型映射", "modelMappingHint": "如果供应商原生提供 Claude 系列模型,通常无需配置。仅在需要将请求映射到不同模型名称时填写。", + "quickSetModels": "一键设置", + "quickSetSuccess": "已将模型名称应用到所有字段", "advancedOptionsToggle": "高级选项", "advancedOptionsHint": "包含 API 格式、认证字段、模型映射等配置。大多数场景下保持默认即可。", "categoryOfficial": "官方", From 34349a2743b6b2efb3e6f3202f9dc2f6579a5168 Mon Sep 17 00:00:00 2001 From: Franklin <5235904+mrFranklin@users.noreply.github.com> Date: Mon, 20 Apr 2026 08:44:38 +0800 Subject: [PATCH 9/9] Add OpenClaw config directory settings (#1518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 张斌 --- src/components/settings/DirectorySettings.tsx | 13 ++++++ src/components/settings/SettingsPage.tsx | 1 + src/hooks/useDirectorySettings.ts | 44 ++++++++++++++++--- src/hooks/useSettings.ts | 17 ++++++- src/hooks/useSettingsForm.ts | 2 + src/i18n/locales/en.json | 3 ++ src/i18n/locales/ja.json | 3 ++ src/i18n/locales/zh.json | 3 ++ src/lib/schemas/settings.ts | 2 + tests/hooks/useDirectorySettings.test.tsx | 29 +++++++++++- tests/hooks/useSettings.test.tsx | 19 +++++++- 11 files changed, 124 insertions(+), 12 deletions(-) diff --git a/src/components/settings/DirectorySettings.tsx b/src/components/settings/DirectorySettings.tsx index c633782cb..d66f09b9c 100644 --- a/src/components/settings/DirectorySettings.tsx +++ b/src/components/settings/DirectorySettings.tsx @@ -16,6 +16,7 @@ interface DirectorySettingsProps { codexDir?: string; geminiDir?: string; opencodeDir?: string; + openclawDir?: string; onDirectoryChange: (app: AppId, value?: string) => void; onBrowseDirectory: (app: AppId) => Promise; onResetDirectory: (app: AppId) => Promise; @@ -31,6 +32,7 @@ export function DirectorySettings({ codexDir, geminiDir, opencodeDir, + openclawDir, onDirectoryChange, onBrowseDirectory, onResetDirectory, @@ -130,6 +132,17 @@ export function DirectorySettings({ onBrowse={() => onBrowseDirectory("opencode")} onReset={() => onResetDirectory("opencode")} /> + + onDirectoryChange("openclaw", val)} + onBrowse={() => onBrowseDirectory("openclaw")} + onReset={() => onResetDirectory("openclaw")} + />

); diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index d10e57f30..688e81683 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -316,6 +316,7 @@ export function SettingsPage({ codexDir={settings.codexConfigDir} geminiDir={settings.geminiConfigDir} opencodeDir={settings.opencodeConfigDir} + openclawDir={settings.openclawConfigDir} onDirectoryChange={updateDirectory} onBrowseDirectory={browseDirectory} onResetDirectory={resetDirectory} diff --git a/src/hooks/useDirectorySettings.ts b/src/hooks/useDirectorySettings.ts index 42e63ef9d..51a28dc4a 100644 --- a/src/hooks/useDirectorySettings.ts +++ b/src/hooks/useDirectorySettings.ts @@ -5,7 +5,13 @@ import { homeDir, join } from "@tauri-apps/api/path"; import { settingsApi, type AppId } from "@/lib/api"; import type { SettingsFormState } from "./useSettingsForm"; -type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini" | "opencode"; +type DirectoryKey = + | "appConfig" + | "claude" + | "codex" + | "gemini" + | "opencode" + | "openclaw"; export interface ResolvedDirectories { appConfig: string; @@ -13,6 +19,7 @@ export interface ResolvedDirectories { codex: string; gemini: string; opencode: string; + openclaw: string; } const sanitizeDir = (value?: string | null): string | undefined => { @@ -46,7 +53,9 @@ const computeDefaultConfigDir = async ( ? ".codex" : app === "gemini" ? ".gemini" - : ".config/opencode"; + : app === "opencode" + ? ".config/opencode" + : ".openclaw"; return await join(home, folder); } catch (error) { console.error( @@ -78,6 +87,7 @@ export interface UseDirectorySettingsResult { codexDir?: string, geminiDir?: string, opencodeDir?: string, + openclawDir?: string, ) => void; } @@ -105,6 +115,7 @@ export function useDirectorySettings({ codex: "", gemini: "", opencode: "", + openclaw: "", }); const [isLoading, setIsLoading] = useState(true); @@ -114,6 +125,7 @@ export function useDirectorySettings({ codex: "", gemini: "", opencode: "", + openclaw: "", }); const initialAppConfigDirRef = useRef(undefined); @@ -130,22 +142,26 @@ export function useDirectorySettings({ codexDir, geminiDir, opencodeDir, + openclawDir, defaultAppConfig, defaultClaudeDir, defaultCodexDir, defaultGeminiDir, defaultOpencodeDir, + defaultOpenclawDir, ] = await Promise.all([ settingsApi.getAppConfigDirOverride(), settingsApi.getConfigDir("claude"), settingsApi.getConfigDir("codex"), settingsApi.getConfigDir("gemini"), settingsApi.getConfigDir("opencode"), + settingsApi.getConfigDir("openclaw"), computeDefaultAppConfigDir(), computeDefaultConfigDir("claude"), computeDefaultConfigDir("codex"), computeDefaultConfigDir("gemini"), computeDefaultConfigDir("opencode"), + computeDefaultConfigDir("openclaw"), ]); if (!active) return; @@ -158,6 +174,7 @@ export function useDirectorySettings({ codex: defaultCodexDir ?? "", gemini: defaultGeminiDir ?? "", opencode: defaultOpencodeDir ?? "", + openclaw: defaultOpenclawDir ?? "", }; setAppConfigDir(normalizedOverride); @@ -169,6 +186,7 @@ export function useDirectorySettings({ codex: codexDir || defaultsRef.current.codex, gemini: geminiDir || defaultsRef.current.gemini, opencode: opencodeDir || defaultsRef.current.opencode, + openclaw: openclawDir || defaultsRef.current.openclaw, }); } catch (error) { console.error( @@ -201,7 +219,9 @@ export function useDirectorySettings({ ? { codexConfigDir: sanitized } : key === "gemini" ? { geminiConfigDir: sanitized } - : { opencodeConfigDir: sanitized }, + : key === "opencode" + ? { opencodeConfigDir: sanitized } + : { openclawConfigDir: sanitized }, ); } @@ -229,7 +249,9 @@ export function useDirectorySettings({ ? "codex" : app === "gemini" ? "gemini" - : "opencode", + : app === "opencode" + ? "opencode" + : "openclaw", value, ); }, @@ -245,7 +267,9 @@ export function useDirectorySettings({ ? "codex" : app === "gemini" ? "gemini" - : "opencode"; + : app === "opencode" + ? "opencode" + : "openclaw"; const currentValue = key === "claude" ? (settings?.claudeConfigDir ?? resolvedDirs.claude) @@ -253,7 +277,9 @@ export function useDirectorySettings({ ? (settings?.codexConfigDir ?? resolvedDirs.codex) : key === "gemini" ? (settings?.geminiConfigDir ?? resolvedDirs.gemini) - : (settings?.opencodeConfigDir ?? resolvedDirs.opencode); + : key === "opencode" + ? (settings?.opencodeConfigDir ?? resolvedDirs.opencode) + : (settings?.openclawConfigDir ?? resolvedDirs.openclaw); try { const picked = await settingsApi.selectConfigDirectory(currentValue); @@ -301,7 +327,9 @@ export function useDirectorySettings({ ? "codex" : app === "gemini" ? "gemini" - : "opencode"; + : app === "opencode" + ? "opencode" + : "openclaw"; if (!defaultsRef.current[key]) { const fallback = await computeDefaultConfigDir(app); if (fallback) { @@ -335,6 +363,7 @@ export function useDirectorySettings({ codexDir?: string, geminiDir?: string, opencodeDir?: string, + openclawDir?: string, ) => { setAppConfigDir(initialAppConfigDirRef.current); setResolvedDirs({ @@ -344,6 +373,7 @@ export function useDirectorySettings({ codex: codexDir ?? defaultsRef.current.codex, gemini: geminiDir ?? defaultsRef.current.gemini, opencode: opencodeDir ?? defaultsRef.current.opencode, + openclaw: openclawDir ?? defaultsRef.current.openclaw, }); }, [], diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index b24e01e43..121f25c72 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -110,6 +110,7 @@ export function useSettings(): UseSettingsResult { sanitizeDir(data?.codexConfigDir), sanitizeDir(data?.geminiConfigDir), sanitizeDir(data?.opencodeConfigDir), + sanitizeDir(data?.openclawConfigDir), ); setRequiresRestart(false); }, [ @@ -135,6 +136,9 @@ export function useSettings(): UseSettingsResult { const sanitizedOpencodeDir = sanitizeDir( mergedSettings.opencodeConfigDir, ); + const sanitizedOpenclawDir = sanitizeDir( + mergedSettings.openclawConfigDir, + ); const { webdavSync: _ignoredWebdavSync, ...restSettings } = mergedSettings; @@ -144,6 +148,7 @@ export function useSettings(): UseSettingsResult { codexConfigDir: sanitizedCodexDir, geminiConfigDir: sanitizedGeminiDir, opencodeConfigDir: sanitizedOpencodeDir, + openclawConfigDir: sanitizedOpenclawDir, language: mergedSettings.language, }; @@ -248,11 +253,15 @@ export function useSettings(): UseSettingsResult { const sanitizedOpencodeDir = sanitizeDir( mergedSettings.opencodeConfigDir, ); + const sanitizedOpenclawDir = sanitizeDir( + mergedSettings.openclawConfigDir, + ); const previousAppDir = initialAppConfigDir; const previousClaudeDir = sanitizeDir(data?.claudeConfigDir); const previousCodexDir = sanitizeDir(data?.codexConfigDir); const previousGeminiDir = sanitizeDir(data?.geminiConfigDir); const previousOpencodeDir = sanitizeDir(data?.opencodeConfigDir); + const previousOpenclawDir = sanitizeDir(data?.openclawConfigDir); const { webdavSync: _ignoredWebdavSync, ...restSettings } = mergedSettings; @@ -262,6 +271,7 @@ export function useSettings(): UseSettingsResult { codexConfigDir: sanitizedCodexDir, geminiConfigDir: sanitizedGeminiDir, opencodeConfigDir: sanitizedOpencodeDir, + openclawConfigDir: sanitizedOpenclawDir, language: mergedSettings.language, }; @@ -358,16 +368,19 @@ export function useSettings(): UseSettingsResult { console.warn("[useSettings] Failed to refresh tray menu", error); } - // 如果 Claude/Codex/Gemini/OpenCode 的目录覆盖发生变化,则立即将"当前使用的供应商"写回对应应用的 live 配置 + // 如果 Claude/Codex/Gemini/OpenCode/OpenClaw 的目录覆盖发生变化,则立即将"当前使用的供应商"写回对应应用的 live 配置 const claudeDirChanged = sanitizedClaudeDir !== previousClaudeDir; const codexDirChanged = sanitizedCodexDir !== previousCodexDir; const geminiDirChanged = sanitizedGeminiDir !== previousGeminiDir; const opencodeDirChanged = sanitizedOpencodeDir !== previousOpencodeDir; + const openclawDirChanged = + sanitizedOpenclawDir !== previousOpenclawDir; if ( claudeDirChanged || codexDirChanged || geminiDirChanged || - opencodeDirChanged + opencodeDirChanged || + openclawDirChanged ) { const syncResult = await syncCurrentProvidersLiveSafe(); if (!syncResult.ok) { diff --git a/src/hooks/useSettingsForm.ts b/src/hooks/useSettingsForm.ts index 0944b91d1..df2323c6a 100644 --- a/src/hooks/useSettingsForm.ts +++ b/src/hooks/useSettingsForm.ts @@ -90,6 +90,7 @@ export function useSettingsForm(): UseSettingsFormResult { codexConfigDir: sanitizeDir(data.codexConfigDir), geminiConfigDir: sanitizeDir(data.geminiConfigDir), opencodeConfigDir: sanitizeDir(data.opencodeConfigDir), + openclawConfigDir: sanitizeDir(data.openclawConfigDir), language: normalizedLanguage, }; @@ -150,6 +151,7 @@ export function useSettingsForm(): UseSettingsFormResult { codexConfigDir: sanitizeDir(serverData.codexConfigDir), geminiConfigDir: sanitizeDir(serverData.geminiConfigDir), opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir), + openclawConfigDir: sanitizeDir(serverData.openclawConfigDir), language: normalizedLanguage, }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 684f1cc92..cefc88203 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -577,10 +577,13 @@ "geminiConfigDirDescription": "Override Gemini configuration directory (.env).", "opencodeConfigDir": "OpenCode Configuration Directory", "opencodeConfigDirDescription": "Override OpenCode configuration directory (opencode.json).", + "openclawConfigDir": "OpenClaw Configuration Directory", + "openclawConfigDirDescription": "Override OpenClaw configuration directory (openclaw.json).", "browsePlaceholderClaude": "e.g., /home//.claude", "browsePlaceholderCodex": "e.g., /home//.codex", "browsePlaceholderGemini": "e.g., /home//.gemini", "browsePlaceholderOpencode": "e.g., /home//.config/opencode", + "browsePlaceholderOpenclaw": "e.g., /home//.openclaw", "browseDirectory": "Browse Directory", "resetDefault": "Reset to default directory (takes effect after saving)", "checkForUpdates": "Check for Updates", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 6d597cd0e..56c3968ae 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -577,10 +577,13 @@ "geminiConfigDirDescription": "Gemini の設定ディレクトリ(.env)を上書きします。", "opencodeConfigDir": "OpenCode 設定ディレクトリ", "opencodeConfigDirDescription": "OpenCode の設定ディレクトリ(opencode.json)を上書きします。", + "openclawConfigDir": "OpenClaw 設定ディレクトリ", + "openclawConfigDirDescription": "OpenClaw の設定ディレクトリ(openclaw.json)を上書きします。", "browsePlaceholderClaude": "例: /home//.claude", "browsePlaceholderCodex": "例: /home//.codex", "browsePlaceholderGemini": "例: /home//.gemini", "browsePlaceholderOpencode": "例: /home//.config/opencode", + "browsePlaceholderOpenclaw": "例: /home//.openclaw", "browseDirectory": "ディレクトリを選択", "resetDefault": "デフォルトに戻す(保存後に反映)", "checkForUpdates": "アップデートを確認", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 47dabcfbe..9059ef323 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -577,10 +577,13 @@ "geminiConfigDirDescription": "覆盖 Gemini 配置目录 (.env)。", "opencodeConfigDir": "OpenCode 配置目录", "opencodeConfigDirDescription": "覆盖 OpenCode 配置目录 (opencode.json)。", + "openclawConfigDir": "OpenClaw 配置目录", + "openclawConfigDirDescription": "覆盖 OpenClaw 配置目录 (openclaw.json)。", "browsePlaceholderClaude": "例如:/home/<你的用户名>/.claude", "browsePlaceholderCodex": "例如:/home/<你的用户名>/.codex", "browsePlaceholderGemini": "例如:/home/<你的用户名>/.gemini", "browsePlaceholderOpencode": "例如:/home/<你的用户名>/.config/opencode", + "browsePlaceholderOpenclaw": "例如:/home/<你的用户名>/.openclaw", "browseDirectory": "浏览目录", "resetDefault": "恢复默认目录(需保存后生效)", "checkForUpdates": "检查更新", diff --git a/src/lib/schemas/settings.ts b/src/lib/schemas/settings.ts index 752251369..aa0dad12e 100644 --- a/src/lib/schemas/settings.ts +++ b/src/lib/schemas/settings.ts @@ -21,6 +21,8 @@ export const settingsSchema = z.object({ claudeConfigDir: directorySchema.nullable().optional(), codexConfigDir: directorySchema.nullable().optional(), geminiConfigDir: directorySchema.nullable().optional(), + opencodeConfigDir: directorySchema.nullable().optional(), + openclawConfigDir: directorySchema.nullable().optional(), // 当前供应商 ID(设备级) currentProviderClaude: z.string().optional(), diff --git a/tests/hooks/useDirectorySettings.test.tsx b/tests/hooks/useDirectorySettings.test.tsx index 96d745c3d..8fa029537 100644 --- a/tests/hooks/useDirectorySettings.test.tsx +++ b/tests/hooks/useDirectorySettings.test.tsx @@ -68,7 +68,8 @@ describe("useDirectorySettings", () => { if (app === "claude") return "/remote/claude"; if (app === "codex") return "/remote/codex"; if (app === "gemini") return "/remote/gemini"; - return "/remote/opencode"; + if (app === "opencode") return "/remote/opencode"; + return "/remote/openclaw"; }); selectConfigDirectoryMock.mockReset(); }); @@ -89,6 +90,7 @@ describe("useDirectorySettings", () => { codex: "/remote/codex", gemini: "/remote/gemini", opencode: "/remote/opencode", + openclaw: "/remote/openclaw", }); }); @@ -211,6 +213,29 @@ describe("useDirectorySettings", () => { expect(result.current.resolvedDirs.appConfig).toBe("/home/mock/.cc-switch"); }); + it("updates openclaw directory when browsing succeeds", async () => { + selectConfigDirectoryMock.mockResolvedValue("/picked/openclaw"); + + const { result } = renderHook(() => + useDirectorySettings({ + settings: createSettings({ openclawConfigDir: undefined }), + onUpdateSettings, + }), + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.browseDirectory("openclaw"); + }); + + expect(selectConfigDirectoryMock).toHaveBeenCalledWith("/remote/openclaw"); + expect(onUpdateSettings).toHaveBeenCalledWith({ + openclawConfigDir: "/picked/openclaw", + }); + expect(result.current.resolvedDirs.openclaw).toBe("/picked/openclaw"); + }); + it("resetAllDirectories applies provided resolved values", async () => { const { result } = renderHook(() => useDirectorySettings({ settings: createSettings(), onUpdateSettings }), @@ -223,6 +248,7 @@ describe("useDirectorySettings", () => { "/server/codex", "/server/gemini", "/server/opencode", + "/server/openclaw", ); }); @@ -230,5 +256,6 @@ describe("useDirectorySettings", () => { expect(result.current.resolvedDirs.codex).toBe("/server/codex"); expect(result.current.resolvedDirs.gemini).toBe("/server/gemini"); expect(result.current.resolvedDirs.opencode).toBe("/server/opencode"); + expect(result.current.resolvedDirs.openclaw).toBe("/server/openclaw"); }); }); diff --git a/tests/hooks/useSettings.test.tsx b/tests/hooks/useSettings.test.tsx index d0a9a5b01..98f11176a 100644 --- a/tests/hooks/useSettings.test.tsx +++ b/tests/hooks/useSettings.test.tsx @@ -72,6 +72,9 @@ const createSettingsFormMock = (overrides: Record = {}) => ({ skipClaudeOnboarding: true, claudeConfigDir: "/claude", codexConfigDir: "/codex", + geminiConfigDir: "/gemini", + opencodeConfigDir: "/opencode", + openclawConfigDir: "/openclaw", language: "zh", }, isLoading: false, @@ -90,6 +93,9 @@ const createDirectorySettingsMock = ( appConfig: "/home/mock/.cc-switch", claude: "/default/claude", codex: "/default/codex", + gemini: "/default/gemini", + opencode: "/default/opencode", + openclaw: "/default/openclaw", }, isLoading: false, initialAppConfigDir: undefined, @@ -132,6 +138,9 @@ describe("useSettings hook", () => { skipClaudeOnboarding: true, claudeConfigDir: "/server/claude", codexConfigDir: "/server/codex", + geminiConfigDir: "/server/gemini", + opencodeConfigDir: "/server/opencode", + openclawConfigDir: "/server/openclaw", language: "zh", }; @@ -218,6 +227,9 @@ describe("useSettings hook", () => { enableClaudePluginIntegration: false, claudeConfigDir: "/server/claude", codexConfigDir: undefined, + geminiConfigDir: "/server/gemini", + opencodeConfigDir: "/server/opencode", + openclawConfigDir: "/server/openclaw", language: "en", }; useSettingsQueryMock.mockReturnValue({ @@ -230,6 +242,7 @@ describe("useSettings hook", () => { ...serverSettings, claudeConfigDir: " /custom/claude ", codexConfigDir: " ", + openclawConfigDir: " /custom/openclaw ", language: "en", enableClaudePluginIntegration: true, // 状态从 false 变为 true }, @@ -253,6 +266,7 @@ describe("useSettings hook", () => { const payload = mutateAsyncMock.mock.calls[0][0] as Settings; expect(payload.claudeConfigDir).toBe("/custom/claude"); expect(payload.codexConfigDir).toBeUndefined(); + expect(payload.openclawConfigDir).toBe("/custom/openclaw"); expect(payload.language).toBe("en"); expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith("/override/app"); // 状态改变,应该调用 API @@ -380,8 +394,9 @@ describe("useSettings hook", () => { expect(directorySettingsMock.resetAllDirectories).toHaveBeenCalledWith( "/server/claude", undefined, - undefined, // geminiConfigDir - undefined, // opencodeConfigDir + "/server/gemini", + "/server/opencode", + "/server/openclaw", ); expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false); });