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:
YoVinchen
2026-04-06 11:12:11 +08:00
parent 8c09d8c7d3
commit 8603d4a373
9 changed files with 468 additions and 33 deletions
+155 -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,
@@ -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();
+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(),
+16 -1
View File
@@ -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)
}
+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;
+186 -20
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)
@@ -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");
}
}
+12
View File
@@ -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)),
}
+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::{
@@ -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,
};
+89 -2
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::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(