Merge branch 'main' into codex/issue-1888-interception-matrix

This commit is contained in:
YoVinchen
2026-04-20 09:56:06 +08:00
72 changed files with 8614 additions and 972 deletions
+9 -2
View File
@@ -18,6 +18,7 @@ pub struct ManagedAuthAccount {
pub avatar_url: Option<String>,
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<String>,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<ManagedAuthDeviceCodeResponse, String> {
@@ -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<String>,
copilot_state: State<'_, CopilotAuthState>,
codex_state: State<'_, CodexOAuthState>,
) -> Result<Option<ManagedAuthAccount>, 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| {
+12 -3
View File
@@ -20,11 +20,12 @@ pub struct CopilotAuthState(pub Arc<RwLock<CopilotAuthManager>>);
/// 返回设备码和用户码,用于 OAuth 认证
#[tauri::command]
pub async fn copilot_start_device_flow(
github_domain: Option<String>,
state: State<'_, CopilotAuthState>,
) -> Result<GitHubDeviceCodeResponse, String> {
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<String>,
state: State<'_, CopilotAuthState>,
) -> Result<bool, 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(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<String>,
state: State<'_, CopilotAuthState>,
) -> Result<Option<GitHubAccount>, 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)
+1
View File
@@ -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,
}
});
+10 -2
View File
@@ -35,18 +35,26 @@ pub fn get_usage_trends(
#[tauri::command]
pub fn get_provider_stats(
state: State<'_, AppState>,
start_date: Option<i64>,
end_date: Option<i64>,
app_type: Option<String>,
) -> Result<Vec<ProviderStats>, 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<i64>,
end_date: Option<i64>,
app_type: Option<String>,
) -> Result<Vec<ModelStats>, AppError> {
state.db.get_model_stats(app_type.as_deref())
state
.db
.get_model_stats(start_date, end_date, app_type.as_deref())
}
/// 获取请求日志列表
+4 -1
View File
@@ -14,6 +14,8 @@ pub struct FailoverQueueItem {
pub provider_id: String,
pub provider_name: String,
pub sort_index: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider_notes: Option<String>,
}
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()))?
+90 -1
View File
@@ -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<Local>,
retain_days: i64,
) -> Result<i64, AppError> {
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<u64, AppError> {
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<Local> {
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> {
+174 -9
View File
@@ -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<ProviderRouter>,
status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
gemini_shadow: Arc<GeminiShadowStore>,
/// 故障转移切换管理器
failover_manager: Arc<FailoverSwitchManager>,
/// AppHandle,用于发射事件和更新托盘
app_handle: Option<tauri::AppHandle>,
/// 请求开始时的"当前供应商 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<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
gemini_shadow: Arc<GeminiShadowStore>,
failover_manager: Arc<FailoverSwitchManager>,
app_handle: Option<tauri::AppHandle>,
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)?
@@ -1147,8 +1165,11 @@ impl RequestForwarder {
let connection_header_tokens =
strip_openrouter_hop_by_hop_headers.then(|| collect_connection_header_tokens(headers));
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() {
@@ -1275,8 +1296,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;
}
@@ -1317,7 +1340,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"),
@@ -1707,6 +1730,7 @@ fn rewrite_claude_transform_endpoint(
endpoint: &str,
api_format: &str,
is_copilot: bool,
body: &Value,
) -> (String, Option<String>) {
let (path, query) = split_endpoint_and_query(endpoint);
let passthrough_query = if is_claude_messages_path(path) {
@@ -1719,6 +1743,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 {
@@ -1737,6 +1791,26 @@ fn rewrite_claude_transform_endpoint(
(rewritten, passthrough_query)
}
fn merge_query_params(base_query: Option<&str>, extra_param: Option<&str>) -> Option<String> {
let mut params: Vec<String> = 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() => {
@@ -1865,6 +1939,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");
@@ -1877,6 +1952,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");
@@ -1885,8 +1961,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"));
@@ -1898,12 +1978,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"));
@@ -1911,6 +2039,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();
+665
View File
@@ -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<String> {
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(""), "");
}
}
+2
View File
@@ -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(),
+24 -7
View File
@@ -15,12 +15,14 @@ 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,
strip_entity_headers_for_rebuilt_body, SseUsageCollector,
strip_entity_headers_for_rebuilt_body, strip_hop_by_hop_response_headers,
SseUsageCollector,
},
server::ProxyState,
types::*,
@@ -144,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<axum::response::Response, ProxyError> {
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 选择流式转换器
@@ -157,6 +161,14 @@ async fn handle_claude_transform(
dyn futures::Stream<Item = Result<Bytes, std::io::Error>> + 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)))
};
@@ -216,10 +228,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());
@@ -245,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)
}
@@ -287,6 +303,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);
+1
View File
@@ -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;
+361 -15
View File
@@ -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<serde_json::Value, ProxyError> {
// 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<serde_json::Value, ProxyError> {
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<serde_json::Value, ProxyError> {
@@ -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");
}
}
@@ -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(),
}
}
}
+328 -54
View File
@@ -24,26 +24,114 @@ use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::{Mutex, RwLock};
/// GitHub OAuth 客户端 IDVS Code 使用的 ID
/// GitHub OAuth 客户端 IDVS 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 基础 URLgithub.com 用 api.github.comGHES 用 {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.comGHES 用 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<String, CopilotAuthError> {
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<reqwest::Error> for CopilotAuthError {
@@ -253,15 +338,19 @@ pub struct GitHubAccount {
pub avatar_url: Option<String>,
/// 认证时间戳
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<GitHubAccount, CopilotAuthError> {
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<GitHubDeviceCodeResponse, CopilotAuthError> {
log::info!("[CopilotAuth] 启动设备码流程");
pub async fn start_device_flow(
&self,
github_domain: Option<&str>,
) -> Result<GitHubDeviceCodeResponse, CopilotAuthError> {
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<Option<GitHubAccount>, 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<Vec<CopilotModel>, 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<CopilotUsageResponse, CopilotAuthError> {
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<String, CopilotAuthError> {
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<Mutex<()>> {
{
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<GitHubUser, CopilotAuthError> {
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");
}
}
+13 -1
View File
@@ -70,6 +70,11 @@ impl GeminiAdapter {
/// 解析 OAuth 凭证
pub fn parse_oauth_credentials(&self, key: &str) -> Option<OAuthCredentials> {
// 防御性 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<String> {
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());
}
@@ -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<String, Value>) -> 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());
}
}
@@ -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<String>, session_id: impl Into<String>) -> 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<String>,
pub name: String,
pub args: Value,
pub thought_signature: Option<String>,
}
impl GeminiToolCallMeta {
pub fn new(
id: Option<impl Into<String>>,
name: impl Into<String>,
args: Value,
thought_signature: Option<impl Into<String>>,
) -> 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<GeminiToolCallMeta>,
}
impl GeminiAssistantTurn {
pub fn new(assistant_content: Value, tool_calls: Vec<GeminiToolCallMeta>) -> 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<GeminiAssistantTurn>,
}
#[derive(Debug, Clone)]
struct GeminiShadowSession {
turns: VecDeque<GeminiAssistantTurn>,
}
impl GeminiShadowSession {
fn new() -> Self {
Self {
turns: VecDeque::new(),
}
}
}
#[derive(Debug, Clone)]
struct GeminiShadowInner {
sessions: HashMap<GeminiShadowKey, GeminiShadowSession>,
session_order: VecDeque<GeminiShadowKey>,
}
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<GeminiShadowInner>,
}
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<String>,
session_id: impl Into<String>,
assistant_content: Value,
tool_calls: Vec<GeminiToolCallMeta>,
) -> 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<Value> {
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<Vec<GeminiToolCallMeta>> {
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<GeminiShadowSessionSnapshot> {
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<GeminiShadowKey>, 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<GeminiShadowKey>, 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());
}
}
+12
View File
@@ -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") {
+2 -5
View File
@@ -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<E: std::error::Error + Send + 'static>(
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;
}
File diff suppressed because it is too large Load Diff
@@ -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<E: std::error::Error + Send +
crate::proxy::sse::append_utf8_safe(&mut buffer, &mut utf8_remainder, &bytes);
// SSE 事件由 \n\n 分隔
while let Some(pos) = buffer.find("\n\n") {
let block = buffer[..pos].to_string();
buffer = buffer[pos + 2..].to_string();
while let Some(block) = take_sse_block(&mut buffer) {
if block.trim().is_empty() {
continue;
}
File diff suppressed because it is too large Load Diff
@@ -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",
})
+2 -5
View File
@@ -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]" {
+130 -8
View File
@@ -7,11 +7,11 @@ 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,
};
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<String> {
.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<HeaderName> = 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!(
"[{}] 上游响应体内容: {}",
@@ -623,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() {
@@ -687,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;
@@ -715,6 +752,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<Database>) -> ProxyState {
ProxyState {
db: db.clone(),
@@ -723,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)),
}
+5 -1
View File
@@ -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<RwLock<std::collections::HashMap<String, (String, String)>>>,
/// 共享的 ProviderRouter(持有熔断器状态,跨请求保持)
pub provider_router: Arc<ProviderRouter>,
/// Gemini Native shadow state,用于 thoughtSignature / tool call 回放
pub gemini_shadow: Arc<GeminiShadowStore>,
/// AppHandle,用于发射事件和更新托盘菜单
pub app_handle: Option<tauri::AppHandle>,
/// 故障转移切换管理器
@@ -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,
};
+69
View File
@@ -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<SessionIdResult> {
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<SessionIdResult> {
// 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();
+41 -1
View File
@@ -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<String> {
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<u8>, 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
// ------------------------------------------------------------------
+27
View File
@@ -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(
+1 -1
View File
@@ -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)
}
+10 -12
View File
@@ -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") {
+18 -4
View File
@@ -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)
}
@@ -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);
}
@@ -1772,7 +1786,7 @@ impl SkillService {
let results: Vec<Result<Vec<DiscoverableSkill>>> =
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 +1795,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 +1862,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)
}
+224 -15
View File
@@ -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};
@@ -55,8 +57,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 +76,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<String>,
}
/// 流式健康检查服务
@@ -143,6 +148,7 @@ impl StreamCheckService {
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
retry_count: effective_config.max_retries,
error_category: None,
}))
}
@@ -275,6 +281,7 @@ impl StreamCheckService {
result,
response_time,
config.degraded_threshold_ms,
&model_to_test,
))
}
@@ -283,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`
@@ -324,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 };
@@ -347,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}")))?
@@ -382,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
@@ -428,8 +463,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
@@ -564,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 原生请求体格式
@@ -671,16 +708,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 {
@@ -693,14 +735,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,
@@ -708,14 +755,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` 字段分发到对应协议的检查器。
@@ -1245,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();
}
@@ -1475,6 +1567,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();
@@ -1529,6 +1666,7 @@ mod tests {
AuthStrategy::Bearer,
"openai_chat",
true,
"gpt-5.4",
);
assert_eq!(url, "https://relay.example/v1/chat/completions");
@@ -1541,6 +1679,7 @@ mod tests {
AuthStrategy::GitHubCopilot,
"openai_chat",
false,
"gpt-5.4",
);
assert_eq!(url, "https://api.githubcopilot.com/chat/completions");
@@ -1553,6 +1692,7 @@ mod tests {
AuthStrategy::GitHubCopilot,
"openai_responses",
false,
"gpt-5.4",
);
assert_eq!(url, "https://api.githubcopilot.com/v1/responses");
@@ -1565,6 +1705,7 @@ mod tests {
AuthStrategy::Bearer,
"openai_chat",
false,
"gpt-5.4",
);
assert_eq!(url, "https://example.com/v1/chat/completions");
@@ -1577,6 +1718,7 @@ mod tests {
AuthStrategy::Bearer,
"openai_responses",
false,
"gpt-5.4",
);
assert_eq!(url, "https://example.com/v1/responses");
@@ -1589,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(
File diff suppressed because it is too large Load Diff
+1 -4
View File
@@ -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 {