mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-28 00:35:32 +08:00
feat(proxy): wire Gemini Native format into proxy core and Claude adapter
Integrate gemini_native api_format throughout the proxy pipeline: - ClaudeAdapter: detect Gemini provider type, Google/GoogleOAuth auth strategies, and suppress Anthropic-specific headers for Gemini targets - Forwarder: Gemini URL resolution, shadow store threading, endpoint rewriting to models/*:generateContent with stream/non-stream variants - Handlers: route Gemini streaming through streaming_gemini adapter and non-streaming through transform_gemini converter - Server/State: add GeminiShadowStore to shared ProxyState - StreamCheck: support gemini_native health check with proper auth headers
This commit is contained in:
@@ -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,
|
||||
@@ -42,12 +45,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,
|
||||
/// 优化器配置
|
||||
@@ -65,9 +71,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,
|
||||
@@ -78,9 +86,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,
|
||||
@@ -877,7 +887,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(),
|
||||
@@ -887,7 +897,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)
|
||||
@@ -903,6 +919,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)?
|
||||
@@ -1019,8 +1037,11 @@ impl RequestForwarder {
|
||||
.ok()
|
||||
.and_then(|u| u.authority().map(|a| a.to_string()));
|
||||
|
||||
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() {
|
||||
@@ -1140,8 +1161,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;
|
||||
}
|
||||
|
||||
@@ -1182,7 +1205,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"),
|
||||
@@ -1530,6 +1553,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) {
|
||||
@@ -1542,6 +1566,32 @@ 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");
|
||||
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 {
|
||||
@@ -1560,6 +1610,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() => {
|
||||
@@ -1688,6 +1758,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");
|
||||
@@ -1700,6 +1771,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");
|
||||
@@ -1708,8 +1780,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"));
|
||||
@@ -1721,12 +1797,45 @@ 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"));
|
||||
}
|
||||
|
||||
#[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"));
|
||||
@@ -1734,6 +1843,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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -15,8 +15,9 @@ 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,
|
||||
@@ -157,6 +158,13 @@ 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()),
|
||||
)))
|
||||
} else {
|
||||
Box::new(Box::pin(create_anthropic_sse_stream(stream)))
|
||||
};
|
||||
@@ -245,6 +253,13 @@ 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(
|
||||
upstream_response,
|
||||
Some(state.gemini_shadow.as_ref()),
|
||||
Some(&ctx.provider.id),
|
||||
Some(&ctx.session_id),
|
||||
)
|
||||
} else {
|
||||
transform::openai_to_anthropic(upstream_response)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -28,6 +29,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",
|
||||
};
|
||||
}
|
||||
@@ -42,6 +44,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",
|
||||
};
|
||||
}
|
||||
@@ -66,25 +69,33 @@ 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> {
|
||||
let cache_key = provider
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.prompt_cache_key.as_deref())
|
||||
.unwrap_or(&provider.id);
|
||||
.and_then(|m| m.prompt_cache_key.as_deref());
|
||||
|
||||
match api_format {
|
||||
"openai_responses" => {
|
||||
super::transform_responses::anthropic_to_responses(body, Some(cache_key))
|
||||
}
|
||||
"openai_chat" => super::transform::anthropic_to_openai(body, Some(cache_key)),
|
||||
"openai_responses" => super::transform_responses::anthropic_to_responses(body, cache_key),
|
||||
"openai_chat" => super::transform::anthropic_to_openai(body, cache_key),
|
||||
"gemini_native" => super::transform_gemini::anthropic_to_gemini_with_shadow(
|
||||
body,
|
||||
shadow_store,
|
||||
Some(&provider.id),
|
||||
session_id,
|
||||
),
|
||||
_ => Ok(body),
|
||||
}
|
||||
}
|
||||
@@ -105,6 +116,15 @@ impl ClaudeAdapter {
|
||||
/// - ClaudeAuth: auth_mode 为 bearer_only
|
||||
/// - Claude: 默认 Anthropic 官方
|
||||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// 检测 GitHub Copilot
|
||||
if self.is_github_copilot(provider) {
|
||||
return ProviderType::GitHubCopilot;
|
||||
@@ -304,14 +324,23 @@ 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 => {
|
||||
if let Some(creds) =
|
||||
super::gemini::GeminiAdapter::new().parse_oauth_credentials(&key)
|
||||
{
|
||||
Some(AuthInfo::with_access_token(key, creds.access_token))
|
||||
} else {
|
||||
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 {
|
||||
@@ -347,6 +376,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::GitHubCopilot => {
|
||||
// 生成请求追踪 ID
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
@@ -402,7 +448,6 @@ impl ProviderAdapter for ClaudeAdapter {
|
||||
),
|
||||
]
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +463,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"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -427,7 +472,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> {
|
||||
@@ -436,7 +487,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)
|
||||
@@ -740,6 +793,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!({
|
||||
@@ -847,11 +918,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,14 @@ mod claude;
|
||||
mod codex;
|
||||
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;
|
||||
@@ -96,6 +100,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") {
|
||||
|
||||
@@ -684,6 +684,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;
|
||||
@@ -720,6 +721,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)),
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
@@ -36,6 +37,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>,
|
||||
/// 故障转移切换管理器
|
||||
@@ -69,6 +72,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,
|
||||
};
|
||||
|
||||
@@ -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::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};
|
||||
|
||||
@@ -309,6 +311,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
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn check_claude_stream(
|
||||
client: &Client,
|
||||
@@ -345,8 +349,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 };
|
||||
|
||||
@@ -360,6 +370,9 @@ impl StreamCheckService {
|
||||
let body = if is_openai_responses {
|
||||
anthropic_to_responses(anthropic_body, Some(&provider.id))
|
||||
.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, Some(&provider.id))
|
||||
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
|
||||
@@ -395,6 +408,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
|
||||
@@ -751,7 +781,13 @@ impl StreamCheckService {
|
||||
auth_strategy: AuthStrategy,
|
||||
api_format: &str,
|
||||
is_full_url: bool,
|
||||
model: &str,
|
||||
) -> String {
|
||||
if api_format == "gemini_native" {
|
||||
let endpoint = format!("/v1beta/models/{model}:streamGenerateContent?alt=sse");
|
||||
return resolve_gemini_native_url(base_url, &endpoint, is_full_url);
|
||||
}
|
||||
|
||||
if is_full_url {
|
||||
return base_url.to_string();
|
||||
}
|
||||
@@ -914,6 +950,7 @@ mod tests {
|
||||
AuthStrategy::Bearer,
|
||||
"openai_chat",
|
||||
true,
|
||||
"gpt-5.4",
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://relay.example/v1/chat/completions");
|
||||
@@ -926,6 +963,7 @@ mod tests {
|
||||
AuthStrategy::GitHubCopilot,
|
||||
"openai_chat",
|
||||
false,
|
||||
"gpt-5.4",
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://api.githubcopilot.com/chat/completions");
|
||||
@@ -938,6 +976,7 @@ mod tests {
|
||||
AuthStrategy::GitHubCopilot,
|
||||
"openai_responses",
|
||||
false,
|
||||
"gpt-5.4",
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://api.githubcopilot.com/v1/responses");
|
||||
@@ -950,6 +989,7 @@ mod tests {
|
||||
AuthStrategy::Bearer,
|
||||
"openai_chat",
|
||||
false,
|
||||
"gpt-5.4",
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://example.com/v1/chat/completions");
|
||||
@@ -962,6 +1002,7 @@ mod tests {
|
||||
AuthStrategy::Bearer,
|
||||
"openai_responses",
|
||||
false,
|
||||
"gpt-5.4",
|
||||
);
|
||||
|
||||
assert_eq!(url, "https://example.com/v1/responses");
|
||||
@@ -974,11 +1015,57 @@ 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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_codex_stream_urls_for_full_url_mode() {
|
||||
let urls = StreamCheckService::resolve_codex_stream_urls(
|
||||
|
||||
Reference in New Issue
Block a user