mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
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.
This commit is contained in:
@@ -50,7 +50,8 @@ pub fn build_gemini_native_url(base_url: &str, endpoint: &str) -> String {
|
||||
|
||||
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 on_google_host = is_google_gemini_host(extract_host(origin));
|
||||
let prefix_path = normalize_gemini_base_path(raw_path, on_google_host);
|
||||
|
||||
let mut url = if prefix_path.is_empty() {
|
||||
format!("{origin}{endpoint_path}")
|
||||
@@ -169,18 +170,24 @@ fn split_origin_and_path(base_url: &str) -> (&str, &str) {
|
||||
(&base_url[..path_start], &base_url[path_start..])
|
||||
}
|
||||
|
||||
fn normalize_gemini_base_path(path: &str) -> String {
|
||||
fn normalize_gemini_base_path(path: &str, on_google_host: bool) -> String {
|
||||
let path = path.trim_end_matches('/');
|
||||
if path.is_empty() || path == "/" {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// 无条件层:Gemini 专属的结构化 `/models/...:method` path。非 Google
|
||||
// host 上也不可能作为 relay 的合法固定端点(这是方法调用,不是资源
|
||||
// 根),统一剥到 `/models/` 之前。
|
||||
for marker in ["/v1beta/models/", "/v1/models/", "/models/"] {
|
||||
if let Some(index) = path.find(marker) {
|
||||
return normalize_prefix(&path[..index]);
|
||||
}
|
||||
}
|
||||
|
||||
// 无条件层:深 OpenAI-compat endpoint 也属于 Gemini 专属语法
|
||||
// (`/openai/chat/completions`、`/openai/responses` 及其版本前缀变体),
|
||||
// 非 Google host 上也不是合理的 relay 终端路径。
|
||||
for suffix in [
|
||||
"/v1beta/openai/chat/completions",
|
||||
"/v1/openai/chat/completions",
|
||||
@@ -188,6 +195,26 @@ fn normalize_gemini_base_path(path: &str) -> String {
|
||||
"/v1beta/openai/responses",
|
||||
"/v1/openai/responses",
|
||||
"/openai/responses",
|
||||
] {
|
||||
if path == suffix {
|
||||
return String::new();
|
||||
}
|
||||
if let Some(prefix) = path.strip_suffix(suffix) {
|
||||
return normalize_prefix(prefix);
|
||||
}
|
||||
}
|
||||
|
||||
// Google-host gated 层:通用版本/资源根后缀(`/v1`、`/v1beta`、
|
||||
// `/models`、`/openai` 及子目录)在 Google / Vertex host 上确实是
|
||||
// 官方命名空间,值得归一化;但在第三方 relay 上可能是合法固定前缀
|
||||
// (`https://relay.example/custom/v1` 等),必须原样保留,否则会把
|
||||
// 出站 URL 改写成 `.../v1beta/models/...` 并打到错误的后端。与
|
||||
// `should_normalize_gemini_full_url` 第二层对称。
|
||||
if !on_google_host {
|
||||
return path.to_string();
|
||||
}
|
||||
|
||||
for suffix in [
|
||||
"/v1beta/openai",
|
||||
"/v1/openai",
|
||||
"/openai",
|
||||
@@ -566,6 +593,143 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Non-full-URL mode tests (`is_full_url == false`).
|
||||
//
|
||||
// Prior to this gate, `build_gemini_native_url` unconditionally stripped
|
||||
// `/v1`, `/v1beta`, `/models`, `/openai` suffixes regardless of host —
|
||||
// so a third-party relay base URL like `https://relay.example/custom/v1`
|
||||
// would be silently rewritten to `.../custom/v1beta/models/...` and
|
||||
// 404 at the relay. The fix applies the same Google-host whitelist
|
||||
// that full-URL mode uses: only Google / Vertex hosts get the generic
|
||||
// version / resource-root suffixes stripped. Gemini-specific grammars
|
||||
// (`/models/...:method` and deep `/openai/*` endpoints) still normalize
|
||||
// on any host — those are unambiguous and not plausible relay terminal
|
||||
// paths.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Regression: non-full-URL + third-party relay base with a `/v1`
|
||||
/// prefix must preserve the prefix so the relay's own routing layer
|
||||
/// decides what to do with the appended method path.
|
||||
#[test]
|
||||
fn preserves_opaque_v1_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/v1/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Companion case: `/v1beta` suffix on a non-Google host must be
|
||||
/// preserved in non-full-URL mode too. Previously this was stripped
|
||||
/// regardless of host.
|
||||
#[test]
|
||||
fn preserves_opaque_v1beta_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/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Companion case: bare `/models` suffix on a non-Google host stays
|
||||
/// as-is. Relay might mount the Gemini-compatible namespace at this
|
||||
/// path prefix specifically.
|
||||
#[test]
|
||||
fn preserves_opaque_models_suffix_in_non_full_url_mode() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/models",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://relay.example/custom/models/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Companion case: bare `/openai` resource-root suffix on a non-Google
|
||||
/// host stays as-is. Only the *deep* OpenAI-compat endpoints
|
||||
/// (`/openai/chat/completions`, `/openai/responses` and variants) are
|
||||
/// stripped unconditionally — those are Gemini-specific grammar.
|
||||
#[test]
|
||||
fn preserves_opaque_openai_suffix_in_non_full_url_mode() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/openai",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://relay.example/custom/openai/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Counter-case: `/v1beta` on the official Gemini host in non-full-URL
|
||||
/// mode must still normalize. This is the most common official-host
|
||||
/// shape pasted from AI Studio documentation.
|
||||
#[test]
|
||||
fn strips_v1_suffix_on_google_host_in_non_full_url_mode() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
url,
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Boundary: structured `/models/...:method` paths are Gemini-specific
|
||||
/// grammar and must be rewritten regardless of host, even in non-full
|
||||
/// mode. A user who pasted a full method URL but forgot to toggle
|
||||
/// full-URL on should still get the expected endpoint.
|
||||
#[test]
|
||||
fn strips_structured_model_method_suffix_regardless_of_host_in_non_full_url_mode() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/v1/models/foo:generateContent",
|
||||
"/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"
|
||||
);
|
||||
}
|
||||
|
||||
/// Boundary: deep OpenAI-compat endpoints are unambiguous Gemini-
|
||||
/// specific terminal paths and are rewritten on any host. Mirrors
|
||||
/// `preserves_custom_proxy_prefix_while_stripping_openai_suffix` for
|
||||
/// the non-full-URL flow.
|
||||
#[test]
|
||||
fn strips_deep_openai_compat_endpoint_on_non_google_host_in_non_full_url_mode() {
|
||||
let url = resolve_gemini_native_url(
|
||||
"https://relay.example/custom/v1beta/openai/chat/completions",
|
||||
"/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.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user