diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index a8f10adf7..7c9fbc124 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -1689,6 +1689,10 @@ fn rewrite_claude_transform_endpoint( 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()) @@ -1940,6 +1944,21 @@ mod tests { 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( diff --git a/src-tauri/src/proxy/gemini_url.rs b/src-tauri/src/proxy/gemini_url.rs index 847d39e80..46c2c0775 100644 --- a/src-tauri/src/proxy/gemini_url.rs +++ b/src-tauri/src/proxy/gemini_url.rs @@ -3,6 +3,22 @@ //! 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); @@ -65,15 +81,12 @@ fn should_normalize_gemini_full_url(base_url: &str) -> bool { let path = path.trim_end_matches('/'); let on_google_host = is_google_gemini_host(extract_host(origin)); - // Unconditional layer: the `/models/...:generateContent` structure is - // Gemini-specific, and `/v1beta*` / deep OpenAI-compat paths - // (`/v1beta/openai/...`, `/openai/chat/completions`, `/openai/responses`) - // are specific enough to Gemini or AI-Studio that treating them as - // structured on any host is safe. + // 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") - || path.ends_with("/v1beta/models") - || path.ends_with("/v1beta/openai") || path.ends_with("/v1beta/openai/chat/completions") || path.ends_with("/v1beta/openai/responses") || path.ends_with("/openai/chat/completions") @@ -84,12 +97,17 @@ fn should_normalize_gemini_full_url(base_url: &str) -> bool { return true; } - // Generic REST suffixes (`/v1`, `/models`, etc.) legitimately appear on - // opaque relays — e.g. `https://relay.example/custom/v1` is a relay - // root, not a Gemini base. Only treat these as "structured Gemini" when - // the host itself is Google's Gemini or Vertex AI endpoint. + // 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("/v1") + && (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") @@ -237,7 +255,7 @@ fn merge_queries(base_query: Option<&str>, endpoint_query: Option<&str>) -> Opti #[cfg(test)] mod tests { - use super::{build_gemini_native_url, resolve_gemini_native_url}; + use super::{build_gemini_native_url, normalize_gemini_model_id, resolve_gemini_native_url}; #[test] fn strips_version_root_for_official_base() { @@ -501,4 +519,105 @@ mod tests { 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" + ); + } + + // ------------------------------------------------------------------ + // 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/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 2d32f6477..1bb630058 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -12,7 +12,7 @@ use std::time::Instant; use crate::app_config::AppType; use crate::error::AppError; use crate::provider::Provider; -use crate::proxy::gemini_url::resolve_gemini_native_url; +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; @@ -598,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 原生请求体格式 @@ -1325,7 +1328,13 @@ impl StreamCheckService { model: &str, ) -> String { if api_format == "gemini_native" { - let endpoint = format!("/v1beta/models/{model}:streamGenerateContent?alt=sse"); + // 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); } @@ -1773,6 +1782,27 @@ mod tests { 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(