mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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(""), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, serde_json::Value>>,
|
||||
) -> 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(
|
||||
|
||||
Reference in New Issue
Block a user