mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
fix(proxy): preserve URL query/fragment and improve error handling
Extract shared URL utilities to handle query strings and fragments correctly. Add race condition fix for URL preview, improve proxy toggle error feedback, and support Codex chat format proxy endpoint.
This commit is contained in:
@@ -24,6 +24,7 @@ pub mod session;
|
||||
pub mod thinking_rectifier;
|
||||
pub(crate) mod types;
|
||||
pub mod url_builder;
|
||||
pub(crate) mod url_utils;
|
||||
pub mod usage;
|
||||
|
||||
// 公开导出给外部使用(commands, services等模块需要)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use crate::proxy::url_utils::{dedup_v1_v1_boundary_safe, split_url_suffix};
|
||||
use reqwest::RequestBuilder;
|
||||
|
||||
/// Claude 适配器
|
||||
@@ -252,7 +253,8 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||
|
||||
let base_trimmed = base_url.trim_end_matches('/');
|
||||
let (base, suffix) = split_url_suffix(base_url);
|
||||
let base_trimmed = base.trim_end_matches('/');
|
||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||
|
||||
// 检测 base_url 是否已经以 API 路径结尾(用户填写了完整路径)
|
||||
@@ -269,28 +271,27 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
.any(|pattern| base_trimmed.to_lowercase().ends_with(pattern));
|
||||
|
||||
// 如果 base_url 已经以 API 路径结尾,直接使用 base_url,不再追加 endpoint
|
||||
let mut base = if base_ends_with_api_path {
|
||||
let base = if base_ends_with_api_path {
|
||||
base_trimmed.to_string()
|
||||
} else {
|
||||
format!("{base_trimmed}/{endpoint_trimmed}")
|
||||
};
|
||||
|
||||
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
|
||||
while base.contains("/v1/v1") {
|
||||
base = base.replace("/v1/v1", "/v1");
|
||||
}
|
||||
let base = dedup_v1_v1_boundary_safe(base);
|
||||
|
||||
// 为 Claude 相关端点添加 ?beta=true 参数
|
||||
// 这是某些上游服务(如 DuckCoding)验证请求来源的关键参数
|
||||
// 注:openai_chat 模式下会转发到 /v1/chat/completions,此处也需要保持一致
|
||||
// 检查最终 URL 是否包含需要 beta 参数的路径,且没有查询参数
|
||||
let needs_beta = (base.contains("/v1/messages") || base.contains("/v1/chat/completions"))
|
||||
&& !base.contains('?');
|
||||
&& !base.contains('?')
|
||||
&& !suffix.starts_with('?');
|
||||
|
||||
if needs_beta {
|
||||
format!("{base}?beta=true")
|
||||
format!("{base}?beta=true{suffix}")
|
||||
} else {
|
||||
base
|
||||
format!("{base}{suffix}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use crate::proxy::url_utils::{dedup_v1_v1_boundary_safe, split_url_suffix};
|
||||
use regex::Regex;
|
||||
use reqwest::RequestBuilder;
|
||||
use std::sync::LazyLock;
|
||||
@@ -138,7 +139,8 @@ impl ProviderAdapter for CodexAdapter {
|
||||
}
|
||||
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
let base_trimmed = base_url.trim_end_matches('/');
|
||||
let (base, suffix) = split_url_suffix(base_url);
|
||||
let base_trimmed = base.trim_end_matches('/');
|
||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||
|
||||
// 检测 base_url 是否已经以 API 路径结尾(用户填写了完整路径)
|
||||
@@ -156,7 +158,7 @@ impl ProviderAdapter for CodexAdapter {
|
||||
|
||||
// 如果 base_url 已经以 API 路径结尾,直接使用 base_url,不再追加 endpoint
|
||||
if base_ends_with_api_path {
|
||||
return base_trimmed.to_string();
|
||||
return format!("{base_trimmed}{suffix}");
|
||||
}
|
||||
|
||||
// OpenAI/Codex 的 base_url 可能是:
|
||||
@@ -185,11 +187,8 @@ impl ProviderAdapter for CodexAdapter {
|
||||
};
|
||||
|
||||
// 去除重复的 /v1/v1(可能由 base_url 与 endpoint 都带版本导致)
|
||||
while url.contains("/v1/v1") {
|
||||
url = url.replace("/v1/v1", "/v1");
|
||||
}
|
||||
|
||||
url
|
||||
url = dedup_v1_v1_boundary_safe(url);
|
||||
format!("{url}{suffix}")
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||
use crate::provider::Provider;
|
||||
use crate::proxy::error::ProxyError;
|
||||
use crate::proxy::url_utils::split_url_suffix;
|
||||
use reqwest::RequestBuilder;
|
||||
|
||||
/// Gemini 适配器
|
||||
@@ -200,7 +201,8 @@ impl ProviderAdapter for GeminiAdapter {
|
||||
}
|
||||
|
||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||
let base_trimmed = base_url.trim_end_matches('/');
|
||||
let (base, suffix) = split_url_suffix(base_url);
|
||||
let base_trimmed = base.trim_end_matches('/');
|
||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||
|
||||
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
|
||||
@@ -214,7 +216,7 @@ impl ProviderAdapter for GeminiAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
url
|
||||
format!("{url}{suffix}")
|
||||
}
|
||||
|
||||
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 提供统一的 URL 构建逻辑,供前端预览和后端代理使用。
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::proxy::url_utils::{dedup_v1_v1_boundary_safe, split_url_suffix};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// URL 预览结果
|
||||
@@ -46,12 +47,33 @@ impl ApiPathPatterns {
|
||||
}
|
||||
}
|
||||
|
||||
fn for_codex(_api_format: Option<&str>) -> Self {
|
||||
// Codex 只支持 /responses 端点
|
||||
Self {
|
||||
direct_endpoint: "/responses",
|
||||
proxy_endpoint: "/responses",
|
||||
full_url_patterns: &["/v1/responses", "/responses"],
|
||||
fn for_codex(api_format: Option<&str>) -> Self {
|
||||
// Codex:
|
||||
// - Direct mode (Codex CLI) hard-codes Responses API: `/responses`
|
||||
// - Proxy mode might target different upstream formats (e.g. Chat Completions)
|
||||
let is_chat = matches!(api_format, Some("chat"));
|
||||
if is_chat {
|
||||
Self {
|
||||
direct_endpoint: "/responses",
|
||||
proxy_endpoint: "/v1/chat/completions",
|
||||
full_url_patterns: &[
|
||||
"/v1/responses",
|
||||
"/responses",
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
],
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
direct_endpoint: "/responses",
|
||||
proxy_endpoint: "/responses",
|
||||
full_url_patterns: &[
|
||||
"/v1/responses",
|
||||
"/responses",
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,9 +88,8 @@ impl ApiPathPatterns {
|
||||
|
||||
/// 检测 URL 是否以指定的 API 路径结尾
|
||||
fn url_ends_with_api_path(url: &str, patterns: &[&str]) -> bool {
|
||||
let trimmed = url.trim_end_matches('/').to_lowercase();
|
||||
// 移除查询参数
|
||||
let path_part = trimmed.split('?').next().unwrap_or(&trimmed);
|
||||
let (base, _) = split_url_suffix(url);
|
||||
let path_part = base.trim_end_matches('/').to_lowercase();
|
||||
patterns
|
||||
.iter()
|
||||
.any(|pattern| path_part.ends_with(&pattern.to_lowercase()))
|
||||
@@ -78,34 +99,31 @@ fn url_ends_with_api_path(url: &str, patterns: &[&str]) -> bool {
|
||||
///
|
||||
/// 始终将 endpoint 拼接到 base_url 后面,不做任何智能检测或去重。
|
||||
fn build_direct_url(base_url: &str, endpoint: &str) -> String {
|
||||
let base_trimmed = base_url.trim_end_matches('/');
|
||||
let (base, suffix) = split_url_suffix(base_url);
|
||||
let base_trimmed = base.trim_end_matches('/');
|
||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||
|
||||
// 直接拼接,不做任何去重
|
||||
format!("{base_trimmed}/{endpoint_trimmed}")
|
||||
format!("{base_trimmed}/{endpoint_trimmed}{suffix}")
|
||||
}
|
||||
|
||||
/// 智能构建 URL(用于代理地址)
|
||||
///
|
||||
/// 如果 base_url 已经以 API 路径结尾,直接返回;否则追加 endpoint。
|
||||
pub fn build_smart_url(base_url: &str, endpoint: &str, full_url_patterns: &[&str]) -> String {
|
||||
let base_trimmed = base_url.trim_end_matches('/');
|
||||
let (base, suffix) = split_url_suffix(base_url);
|
||||
let base_trimmed = base.trim_end_matches('/');
|
||||
let endpoint_trimmed = endpoint.trim_start_matches('/');
|
||||
|
||||
// 检测 base_url 是否已经以 API 路径结尾
|
||||
if url_ends_with_api_path(base_trimmed, full_url_patterns) {
|
||||
return base_trimmed.to_string();
|
||||
return format!("{base_trimmed}{suffix}");
|
||||
}
|
||||
|
||||
// 拼接 URL
|
||||
let mut url = format!("{base_trimmed}/{endpoint_trimmed}");
|
||||
|
||||
// 去重 /v1/v1
|
||||
while url.contains("/v1/v1") {
|
||||
url = url.replace("/v1/v1", "/v1");
|
||||
}
|
||||
|
||||
url
|
||||
let url = format!("{base_trimmed}/{endpoint_trimmed}");
|
||||
let url = dedup_v1_v1_boundary_safe(url);
|
||||
format!("{url}{suffix}")
|
||||
}
|
||||
|
||||
/// 构建 URL 预览
|
||||
@@ -239,6 +257,17 @@ mod tests {
|
||||
assert!(!preview.is_full_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_preview_codex_chat_proxy_endpoint() {
|
||||
let preview = build_url_preview(&AppType::Codex, "https://api.openai.com", Some("chat"));
|
||||
assert_eq!(preview.direct_url, "https://api.openai.com/responses");
|
||||
assert_eq!(
|
||||
preview.proxy_url,
|
||||
"https://api.openai.com/v1/chat/completions"
|
||||
);
|
||||
assert!(!preview.is_full_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_url_preview_codex_full_url() {
|
||||
// 全链接时:直连会硬拼接后缀,代理保持原地址
|
||||
@@ -315,6 +344,38 @@ mod tests {
|
||||
assert_eq!(preview.proxy_url, "https://api.example.com/v1/messages");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_suffix_preserved_in_preview() {
|
||||
let preview = build_url_preview(
|
||||
&AppType::Claude,
|
||||
"https://api.example.com?beta=true",
|
||||
Some("anthropic"),
|
||||
);
|
||||
assert_eq!(
|
||||
preview.direct_url,
|
||||
"https://api.example.com/v1/messages?beta=true"
|
||||
);
|
||||
assert_eq!(
|
||||
preview.proxy_url,
|
||||
"https://api.example.com/v1/messages?beta=true"
|
||||
);
|
||||
assert!(!preview.is_full_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fragment_suffix_preserved_and_full_url_detected() {
|
||||
let preview = build_url_preview(
|
||||
&AppType::Claude,
|
||||
"https://api.example.com/v1/messages#frag",
|
||||
Some("anthropic"),
|
||||
);
|
||||
assert_eq!(
|
||||
preview.proxy_url,
|
||||
"https://api.example.com/v1/messages#frag"
|
||||
);
|
||||
assert!(preview.is_full_url);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_url_detection_by_api_format() {
|
||||
// anthropic 格式:只有 /messages 结尾才是全链接
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! URL utilities shared across proxy modules.
|
||||
//!
|
||||
//! This module intentionally avoids full URL parsing to keep behavior aligned with
|
||||
//! existing "stringly-typed" base_url configurations while fixing common edge cases
|
||||
//! (query/fragment handling and safe path de-duplication).
|
||||
|
||||
/// Split a URL-like string into `(base, suffix)` where suffix starts with `?` or `#`.
|
||||
///
|
||||
/// Example:
|
||||
/// - `https://x/v1?token=1` => (`https://x/v1`, `?token=1`)
|
||||
/// - `https://x/v1#frag` => (`https://x/v1`, `#frag`)
|
||||
pub(crate) fn split_url_suffix(input: &str) -> (&str, &str) {
|
||||
match input.find(['?', '#']) {
|
||||
Some(idx) => (&input[..idx], &input[idx..]),
|
||||
None => (input, ""),
|
||||
}
|
||||
}
|
||||
|
||||
/// De-duplicate repeated `/v1/v1` only when it occurs on a segment boundary.
|
||||
///
|
||||
/// This avoids corrupting valid paths such as `/v1/v1beta/...`.
|
||||
pub(crate) fn dedup_v1_v1_boundary_safe(mut url: String) -> String {
|
||||
const NEEDLE: &str = "/v1/v1";
|
||||
let mut search_start = 0usize;
|
||||
|
||||
loop {
|
||||
let Some(rel_pos) = url[search_start..].find(NEEDLE) else {
|
||||
break;
|
||||
};
|
||||
let pos = search_start + rel_pos;
|
||||
let after = pos + NEEDLE.len();
|
||||
|
||||
let boundary_ok = after == url.len()
|
||||
|| matches!(
|
||||
url.as_bytes().get(after),
|
||||
Some(b'/') | Some(b'?') | Some(b'#')
|
||||
);
|
||||
|
||||
if boundary_ok {
|
||||
url.replace_range(pos..after, "/v1");
|
||||
// Continue searching from the same position in case we created a new boundary match.
|
||||
search_start = pos;
|
||||
} else {
|
||||
// Skip forward to find later occurrences that might be valid boundary matches.
|
||||
search_start = pos + 1;
|
||||
}
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn split_url_suffix_handles_query() {
|
||||
let (base, suffix) = split_url_suffix("https://example.com/v1?token=1");
|
||||
assert_eq!(base, "https://example.com/v1");
|
||||
assert_eq!(suffix, "?token=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_url_suffix_handles_fragment() {
|
||||
let (base, suffix) = split_url_suffix("https://example.com/v1#frag");
|
||||
assert_eq!(base, "https://example.com/v1");
|
||||
assert_eq!(suffix, "#frag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_v1_v1_only_on_boundary() {
|
||||
let url = "https://example.com/v1/v1/messages".to_string();
|
||||
assert_eq!(
|
||||
dedup_v1_v1_boundary_safe(url),
|
||||
"https://example.com/v1/messages"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_v1_v1_does_not_corrupt_v1beta() {
|
||||
let url = "https://example.com/v1/v1beta/models".to_string();
|
||||
assert_eq!(
|
||||
dedup_v1_v1_boundary_safe(url.clone()),
|
||||
"https://example.com/v1/v1beta/models"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedup_v1_v1_skips_v1beta_but_dedups_later_occurrence() {
|
||||
let url = "https://example.com/v1/v1beta/v1/v1/messages".to_string();
|
||||
assert_eq!(
|
||||
dedup_v1_v1_boundary_safe(url),
|
||||
"https://example.com/v1/v1beta/v1/messages"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user