v0.14.8: Antigravity OAuth 支持复制授权 URL 到指纹浏览器

- 新增 Antigravity OAuth 授权 URL 复制功能,支持多账号场景
- 用户可以复制授权 URL 到指纹浏览器完成登录
- 后端启动服务器后通过 Tauri 事件发送授权 URL
- 服务器在后台等待回调(5分钟超时)
- 添加 Gemini 原生协议支持路由
This commit is contained in:
coso
2025-12-20 22:41:36 +08:00
parent 0329edaed0
commit 3e4e455c29
13 changed files with 2063 additions and 81 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.14.6",
"version": "0.14.8",
"type": "module",
"repository": {
"type": "git",
+1 -1
View File
@@ -3367,7 +3367,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.14.5"
version = "0.14.8"
dependencies = [
"anyhow",
"async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "proxycast"
version = "0.14.6"
version = "0.14.8"
description = "AI API Proxy Desktop App"
authors = ["you"]
edition = "2021"
+118 -1
View File
@@ -12,7 +12,7 @@ use chrono::Utc;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tauri::State;
use tauri::{Emitter, State};
use uuid::Uuid;
pub struct ProviderPoolServiceState(pub Arc<ProviderPoolService>);
@@ -1132,3 +1132,120 @@ pub struct MigrationResultResponse {
/// 错误信息列表
pub errors: Vec<String>,
}
/// 获取 Antigravity OAuth 授权 URL 并等待回调(不自动打开浏览器)
///
/// 启动服务器后通过事件发送授权 URL,然后等待回调
/// 成功后返回凭证
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AntigravityAuthUrlResponse {
pub auth_url: String,
}
#[tauri::command]
pub async fn get_antigravity_auth_url_and_wait(
app: tauri::AppHandle,
db: State<'_, DbConnection>,
pool_service: State<'_, ProviderPoolServiceState>,
name: Option<String>,
skip_project_id_fetch: Option<bool>,
) -> Result<ProviderCredential, String> {
use crate::providers::antigravity;
tracing::info!("[Antigravity OAuth] 启动服务器并获取授权 URL");
// 启动服务器并获取授权 URL
let (auth_url, wait_future) =
antigravity::start_oauth_server_and_get_url(skip_project_id_fetch.unwrap_or(false))
.await
.map_err(|e| format!("启动 OAuth 服务器失败: {}", e))?;
tracing::info!("[Antigravity OAuth] 授权 URL: {}", auth_url);
// 通过事件发送授权 URL 给前端
let _ = app.emit(
"antigravity-auth-url",
AntigravityAuthUrlResponse {
auth_url: auth_url.clone(),
},
);
// 等待回调
let result = wait_future.await.map_err(|e| e.to_string())?;
tracing::info!(
"[Antigravity OAuth] 登录成功,凭证保存到: {}",
result.creds_file_path
);
// 从凭证中获取 project_id
let project_id = result.credentials.projectId.clone();
// 添加到凭证池
let credential = pool_service.0.add_credential(
&db,
"antigravity",
CredentialData::AntigravityOAuth {
creds_file_path: result.creds_file_path,
project_id,
},
name,
Some(true),
None,
)?;
tracing::info!(
"[Antigravity OAuth] 凭证已添加到凭证池: {}",
credential.uuid
);
Ok(credential)
}
/// 启动 Antigravity OAuth 登录流程
///
/// 打开浏览器让用户登录 Google 账号,获取 Antigravity 凭证
#[tauri::command]
pub async fn start_antigravity_oauth_login(
db: State<'_, DbConnection>,
pool_service: State<'_, ProviderPoolServiceState>,
name: Option<String>,
skip_project_id_fetch: Option<bool>,
) -> Result<ProviderCredential, String> {
use crate::providers::antigravity;
tracing::info!("[Antigravity OAuth] 开始 OAuth 登录流程");
// 启动 OAuth 登录
let result = antigravity::start_oauth_login(skip_project_id_fetch.unwrap_or(false))
.await
.map_err(|e| format!("Antigravity OAuth 登录失败: {}", e))?;
tracing::info!(
"[Antigravity OAuth] 登录成功,凭证保存到: {}",
result.creds_file_path
);
// 从凭证中获取 project_id
let project_id = result.credentials.projectId.clone();
// 添加到凭证池
let credential = pool_service.0.add_credential(
&db,
"antigravity",
CredentialData::AntigravityOAuth {
creds_file_path: result.creds_file_path,
project_id,
},
name,
Some(true),
None,
)?;
tracing::info!(
"[Antigravity OAuth] 凭证已添加到凭证池: {}",
credential.uuid
);
Ok(credential)
}
+224 -43
View File
@@ -1,9 +1,11 @@
//! OpenAI 格式转换为 Antigravity (Gemini) 格式
use crate::models::openai::*;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Antigravity/Gemini 内容部分
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GeminiPart {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
@@ -16,6 +18,7 @@ pub struct GeminiPart {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InlineData {
pub mime_type: String,
pub data: String,
@@ -23,12 +26,16 @@ pub struct InlineData {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeminiFunctionCall {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub name: String,
pub args: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeminiFunctionResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub name: String,
pub response: serde_json::Value,
}
@@ -42,6 +49,7 @@ pub struct GeminiContent {
/// Antigravity/Gemini 工具定义
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GeminiTool {
pub function_declarations: Vec<GeminiFunctionDeclaration>,
}
@@ -69,12 +77,23 @@ pub struct GeminiGenerationConfig {
pub top_k: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_sequences: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub candidate_count: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking_config: Option<ThinkingConfig>,
}
/// Antigravity 请求体
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AntigravityRequestBody {
pub struct ThinkingConfig {
pub include_thoughts: bool,
pub thinking_budget: i32,
}
/// Antigravity 请求体内部结构
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AntigravityRequestInner {
pub contents: Vec<GeminiContent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_instruction: Option<GeminiContent>,
@@ -82,10 +101,59 @@ pub struct AntigravityRequestBody {
pub generation_config: Option<GeminiGenerationConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<GeminiTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_config: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
/// 生成随机请求 ID
fn generate_request_id() -> String {
format!("agent-{}", Uuid::new_v4())
}
/// 生成随机会话 ID
fn generate_session_id() -> String {
let uuid = Uuid::new_v4();
let bytes = uuid.as_bytes();
let n: u64 = u64::from_le_bytes([
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
]) % 9_000_000_000_000_000_000;
format!("-{}", n)
}
/// 模型名称映射
fn model_mapping(model: &str) -> &str {
match model {
"claude-sonnet-4-5-thinking" => "claude-sonnet-4-5",
"claude-opus-4-5" => "claude-opus-4-5-thinking",
"gemini-2.5-flash-thinking" => "gemini-2.5-flash",
"gemini-2.5-computer-use-preview-10-2025" => "rev19-uic3-1p",
"gemini-3-pro-image-preview" => "gemini-3-pro-image",
"gemini-3-pro-preview" => "gemini-3-pro-high",
"gemini-claude-sonnet-4-5" => "claude-sonnet-4-5",
"gemini-claude-sonnet-4-5-thinking" => "claude-sonnet-4-5-thinking",
_ => model,
}
}
/// 是否启用思维链
fn is_enable_thinking(model: &str) -> bool {
model.ends_with("-thinking")
|| model == "gemini-2.5-pro"
|| model.starts_with("gemini-3-pro-")
|| model == "rev19-uic3-1p"
|| model == "gpt-oss-120b-medium"
}
/// 将 OpenAI ChatCompletionRequest 转换为 Antigravity 请求体
pub fn convert_openai_to_antigravity(request: &ChatCompletionRequest) -> serde_json::Value {
pub fn convert_openai_to_antigravity_with_context(
request: &ChatCompletionRequest,
project_id: &str,
) -> serde_json::Value {
let actual_model = model_mapping(&request.model);
let enable_thinking = is_enable_thinking(&request.model);
let mut contents: Vec<GeminiContent> = Vec::new();
let mut system_instruction: Option<GeminiContent> = None;
@@ -116,35 +184,67 @@ pub fn convert_openai_to_antigravity(request: &ChatCompletionRequest) -> serde_j
}
}
"assistant" => {
let parts = convert_assistant_content(msg);
let parts = convert_assistant_content(msg, &contents);
if !parts.is_empty() {
contents.push(GeminiContent {
role: "model".to_string(),
parts,
});
// 检查是否需要合并到上一条 model 消息
let should_merge = if let Some(last) = contents.last() {
last.role == "model"
&& msg.tool_calls.is_some()
&& msg.get_content_text().is_empty()
} else {
false
};
if should_merge {
if let Some(last) = contents.last_mut() {
last.parts.extend(parts);
}
} else {
contents.push(GeminiContent {
role: "model".to_string(),
parts,
});
}
}
}
"tool" => {
// Tool 响应需要合并到 user 消息
// Tool 响应
let tool_id = msg.tool_call_id.clone().unwrap_or_default();
let content = msg.get_content_text();
// 尝试解析为 JSON,否则包装为对象
let response_value = serde_json::from_str(&content)
.unwrap_or_else(|_| serde_json::json!({ "result": content }));
// 从之前的 model 消息中找到对应的 functionCall name
let function_name = find_function_name(&contents, &tool_id);
contents.push(GeminiContent {
role: "user".to_string(),
parts: vec![GeminiPart {
text: None,
inline_data: None,
function_call: None,
function_response: Some(GeminiFunctionResponse {
name: tool_id,
response: response_value,
}),
}],
});
let response_value = serde_json::json!({ "output": content });
let function_response = GeminiPart {
text: None,
inline_data: None,
function_call: None,
function_response: Some(GeminiFunctionResponse {
id: Some(tool_id),
name: function_name,
response: response_value,
}),
};
// 检查是否需要合并到上一条 user 消息
let should_merge = if let Some(last) = contents.last() {
last.role == "user" && last.parts.iter().any(|p| p.function_response.is_some())
} else {
false
};
if should_merge {
if let Some(last) = contents.last_mut() {
last.parts.push(function_response);
}
} else {
contents.push(GeminiContent {
role: "user".to_string(),
parts: vec![function_response],
});
}
}
_ => {}
}
@@ -152,40 +252,120 @@ pub fn convert_openai_to_antigravity(request: &ChatCompletionRequest) -> serde_j
// 构建生成配置
let generation_config = Some(GeminiGenerationConfig {
temperature: request.temperature,
max_output_tokens: request.max_tokens.map(|t| t as i32),
top_p: None,
top_k: None,
stop_sequences: None,
temperature: request.temperature.or(Some(1.0)),
max_output_tokens: request.max_tokens.map(|t| t as i32).or(Some(8096)),
top_p: Some(0.85),
top_k: Some(50),
stop_sequences: Some(vec![
"<|user|>".to_string(),
"<|bot|>".to_string(),
"<|context_request|>".to_string(),
"<|endoftext|>".to_string(),
"<|end_of_turn|>".to_string(),
]),
candidate_count: Some(1),
thinking_config: Some(ThinkingConfig {
include_thoughts: enable_thinking,
thinking_budget: if enable_thinking { 1024 } else { 0 },
}),
});
// 转换工具
let tools = request.tools.as_ref().map(|tools| {
vec![GeminiTool {
function_declarations: tools
.iter()
.map(|t| GeminiFunctionDeclaration {
tools
.iter()
.map(|t| GeminiTool {
function_declarations: vec![GeminiFunctionDeclaration {
name: t.function.name.clone(),
description: t.function.description.clone(),
parameters: t.function.parameters.clone(),
})
.collect(),
}]
parameters: clean_parameters(t.function.parameters.clone()),
}],
})
.collect()
});
let body = AntigravityRequestBody {
let tool_config = if tools.is_some() {
Some(serde_json::json!({
"functionCallingConfig": {
"mode": "VALIDATED"
}
}))
} else {
None
};
let inner = AntigravityRequestInner {
contents,
system_instruction,
generation_config,
tools,
tool_config,
session_id: Some(generate_session_id()),
};
// 包装为 Antigravity 请求格式
// 构建完整的 Antigravity 请求体
serde_json::json!({
"request": body
"project": project_id,
"requestId": generate_request_id(),
"request": inner,
"model": actual_model,
"userAgent": "antigravity"
})
}
/// 从之前的 model 消息中找到对应的 functionCall name
fn find_function_name(contents: &[GeminiContent], tool_id: &str) -> String {
for content in contents.iter().rev() {
if content.role == "model" {
for part in &content.parts {
if let Some(fc) = &part.function_call {
if fc.id.as_deref() == Some(tool_id) {
return fc.name.clone();
}
}
}
}
}
String::new()
}
/// 清理参数中不需要的字段
fn clean_parameters(params: Option<serde_json::Value>) -> Option<serde_json::Value> {
params.map(|v| clean_value(v))
}
fn clean_value(value: serde_json::Value) -> serde_json::Value {
const EXCLUDED_KEYS: &[&str] = &[
"$schema",
"additionalProperties",
"minLength",
"maxLength",
"minItems",
"maxItems",
"uniqueItems",
];
match value {
serde_json::Value::Object(map) => {
let cleaned: serde_json::Map<String, serde_json::Value> = map
.into_iter()
.filter(|(k, _)| !EXCLUDED_KEYS.contains(&k.as_str()))
.map(|(k, v)| (k, clean_value(v)))
.collect();
serde_json::Value::Object(cleaned)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(clean_value).collect())
}
other => other,
}
}
/// 兼容旧接口
pub fn convert_openai_to_antigravity(request: &ChatCompletionRequest) -> serde_json::Value {
convert_openai_to_antigravity_with_context(request, "")
}
/// 转换用户消息内容
fn convert_user_content(msg: &ChatMessage) -> Vec<GeminiPart> {
let mut parts = Vec::new();
@@ -234,14 +414,14 @@ fn convert_user_content(msg: &ChatMessage) -> Vec<GeminiPart> {
}
/// 转换助手消息内容
fn convert_assistant_content(msg: &ChatMessage) -> Vec<GeminiPart> {
fn convert_assistant_content(msg: &ChatMessage, _contents: &[GeminiContent]) -> Vec<GeminiPart> {
let mut parts = Vec::new();
// 文本内容
let text = msg.get_content_text();
if !text.is_empty() {
parts.push(GeminiPart {
text: Some(text),
text: Some(text.trim_end().to_string()),
inline_data: None,
function_call: None,
function_response: None,
@@ -258,8 +438,9 @@ fn convert_assistant_content(msg: &ChatMessage) -> Vec<GeminiPart> {
text: None,
inline_data: None,
function_call: Some(GeminiFunctionCall {
id: Some(tc.id.clone()),
name: tc.function.name.clone(),
args,
args: serde_json::json!({ "query": args }),
}),
function_response: None,
});
+2
View File
@@ -1681,6 +1681,8 @@ pub fn run() {
commands::provider_pool_cmd::debug_kiro_credentials,
commands::provider_pool_cmd::test_user_credentials,
commands::provider_pool_cmd::migrate_private_config_to_pool,
commands::provider_pool_cmd::start_antigravity_oauth_login,
commands::provider_pool_cmd::get_antigravity_auth_url_and_wait,
// Route commands
commands::route_cmd::get_available_routes,
commands::route_cmd::get_route_curl_examples,
@@ -334,6 +334,7 @@ impl ProviderCredential {
/// 检查两个来源的排除列表:
/// 1. `not_supported_models` - 通用的不支持模型列表(精确匹配)
/// 2. `excluded_models` - 来自 CredentialData::GeminiApiKey 的排除列表(支持通配符)
/// 3. Antigravity 凭证只支持特定的模型列表
pub fn supports_model(&self, model: &str) -> bool {
// 检查通用的不支持模型列表(精确匹配)
if self.not_supported_models.contains(&model.to_string()) {
@@ -352,6 +353,19 @@ impl ProviderCredential {
}
}
// Antigravity 凭证只支持特定的模型
if let CredentialData::AntigravityOAuth { .. } = &self.credential {
// Antigravity 支持的模型列表
const ANTIGRAVITY_SUPPORTED_MODELS: &[&str] = &[
"gemini-3-pro-preview",
"gemini-3-pro-image-preview",
"gemini-2.5-computer-use-preview-10-2025",
"gemini-claude-sonnet-4-5",
"gemini-claude-sonnet-4-5-thinking",
];
return ANTIGRAVITY_SUPPORTED_MODELS.contains(&model);
}
true
}
File diff suppressed because it is too large Load Diff
+351 -6
View File
@@ -5,7 +5,7 @@ use crate::config::{
};
use crate::converter::anthropic_to_openai::convert_anthropic_to_openai;
use crate::converter::openai_to_antigravity::{
convert_antigravity_to_openai_response, convert_openai_to_antigravity,
convert_antigravity_to_openai_response, convert_openai_to_antigravity_with_context,
};
use crate::credential::CredentialSyncService;
use crate::database::dao::provider_pool::ProviderPoolDao;
@@ -14,6 +14,7 @@ use crate::injection::Injector;
use crate::logger::LogStore;
use crate::models::anthropic::*;
use crate::models::openai::*;
use crate::models::provider_pool_model::CredentialData;
use crate::models::route_model::{RouteInfo, RouteListResponse};
use crate::processor::{RequestContext, RequestProcessor};
use crate::providers::antigravity::AntigravityProvider;
@@ -772,6 +773,8 @@ async fn run_server(
.route("/v1/chat/completions", post(chat_completions))
.route("/v1/messages", post(anthropic_messages))
.route("/v1/messages/count_tokens", post(count_tokens))
// Gemini 原生协议路由
.route("/v1/gemini/*path", post(gemini_generate_content))
// WebSocket 路由
.route("/v1/ws", get(ws_upgrade_handler))
.route("/ws", get(ws_upgrade_handler))
@@ -2016,6 +2019,334 @@ async fn count_tokens(
.into_response()
}
/// Gemini 原生协议处理
/// 路由: POST /v1/gemini/{model}:{method}
/// 例如: /v1/gemini/gemini-3-pro-preview:generateContent
async fn gemini_generate_content(
State(state): State<AppState>,
headers: HeaderMap,
Path(path): Path<String>,
Json(request): Json<serde_json::Value>,
) -> Response {
if let Err(e) = verify_api_key(&headers, &state.api_key).await {
return e.into_response();
}
// 解析路径: {model}:{method}
// 例如: gemini-3-pro-preview:generateContent
let parts: Vec<&str> = path.splitn(2, ':').collect();
if parts.len() != 2 {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": {
"message": format!("无效的路径格式: {},期望格式: model:method", path)
}
})),
)
.into_response();
}
let model = parts[0];
let method = parts[1];
state.logs.write().await.add(
"info",
&format!(
"[GEMINI] POST /v1/gemini/{} model={} method={}",
path, model, method
),
);
// 目前只支持 generateContent 方法
if method != "generateContent" && method != "streamGenerateContent" {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": {
"message": format!("不支持的方法: {},目前只支持 generateContent", method)
}
})),
)
.into_response();
}
let is_stream = method == "streamGenerateContent";
// 获取默认 provider
let default_provider = state.default_provider.read().await.clone();
// 尝试从凭证池中选择 Antigravity 凭证
let credential = match &state.db {
Some(db) => state
.pool_service
.select_credential(db, &default_provider, Some(model))
.ok()
.flatten(),
None => None,
};
let cred = match credential {
Some(c) => c,
None => {
return (
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": {
"message": "没有可用的 Antigravity 凭证,请先添加凭证"
}
})),
)
.into_response();
}
};
state.logs.write().await.add(
"info",
&format!(
"[GEMINI] 使用凭证: type={} name={:?} uuid={}",
cred.provider_type,
cred.name,
&cred.uuid[..8]
),
);
// 调用 Antigravity Provider
match &cred.credential {
CredentialData::AntigravityOAuth {
creds_file_path,
project_id,
} => {
let mut antigravity = AntigravityProvider::new();
if let Err(e) = antigravity
.load_credentials_from_path(creds_file_path)
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": {
"message": format!("加载 Antigravity 凭证失败: {}", e)
}
})),
)
.into_response();
}
// 检查并刷新 token
if antigravity.is_token_expiring_soon() {
if let Err(e) = antigravity.refresh_token().await {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({
"error": {
"message": format!("Token 刷新失败: {}", e)
}
})),
)
.into_response();
}
}
// 设置项目 ID
if let Some(pid) = project_id {
antigravity.project_id = Some(pid.clone());
} else if antigravity.project_id.is_none() {
// 如果凭证中没有 project_id,尝试从 API 获取或生成随机 ID
if let Err(e) = antigravity.discover_project().await {
tracing::warn!("[Antigravity] 获取项目 ID 失败: {},使用随机生成的 ID", e);
// 生成随机项目 ID
let uuid = uuid::Uuid::new_v4();
let bytes = uuid.as_bytes();
let adjectives = ["useful", "bright", "swift", "calm", "bold"];
let nouns = ["fuze", "wave", "spark", "flow", "core"];
let adj = adjectives[(bytes[0] as usize) % adjectives.len()];
let noun = nouns[(bytes[1] as usize) % nouns.len()];
let random_part: String = uuid.to_string()[..5].to_lowercase();
antigravity.project_id = Some(format!("{}-{}-{}", adj, noun, random_part));
}
}
let proj_id = antigravity.project_id.clone().unwrap_or_else(|| {
// 最后的后备:生成随机 ID
let uuid = uuid::Uuid::new_v4();
format!("proxycast-{}", &uuid.to_string()[..8])
});
state
.logs
.write()
.await
.add("debug", &format!("[GEMINI] 使用 project_id: {}", proj_id));
// 构建 Antigravity 请求体
// 直接使用用户传入的 Gemini 格式请求,只添加必要的字段
let antigravity_request = build_gemini_native_request(&request, model, &proj_id);
state.logs.write().await.add(
"debug",
&format!(
"[GEMINI] 请求体: {}",
serde_json::to_string(&antigravity_request).unwrap_or_default()
),
);
if is_stream {
// 流式响应 - 暂不支持,返回错误
return (
StatusCode::NOT_IMPLEMENTED,
Json(serde_json::json!({
"error": {
"message": "流式响应暂不支持,请使用 generateContent"
}
})),
)
.into_response();
}
// 非流式响应
match antigravity
.call_api("generateContent", &antigravity_request)
.await
{
Ok(resp) => {
state.logs.write().await.add(
"info",
&format!(
"[GEMINI] 响应成功: {}",
serde_json::to_string(&resp)
.unwrap_or_default()
.chars()
.take(200)
.collect::<String>()
),
);
// 直接返回 Gemini 格式响应
Json(resp).into_response()
}
Err(e) => {
state
.logs
.write()
.await
.add("error", &format!("[GEMINI] 请求失败: {}", e));
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": {
"message": e.to_string()
}
})),
)
.into_response()
}
}
}
_ => (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": {
"message": "Gemini 原生协议只支持 Antigravity 凭证"
}
})),
)
.into_response(),
}
}
/// 构建 Gemini 原生请求体
/// 将用户传入的 Gemini 格式请求转换为 Antigravity 请求格式
fn build_gemini_native_request(
request: &serde_json::Value,
model: &str,
project_id: &str,
) -> serde_json::Value {
use crate::converter::openai_to_antigravity::ThinkingConfig;
// 模型名称映射
let actual_model = match model {
"gemini-2.5-computer-use-preview-10-2025" => "rev19-uic3-1p",
"gemini-3-pro-image-preview" => "gemini-3-pro-image",
"gemini-3-pro-preview" => "gemini-3-pro-high",
"gemini-claude-sonnet-4-5" => "claude-sonnet-4-5",
"gemini-claude-sonnet-4-5-thinking" => "claude-sonnet-4-5-thinking",
_ => model,
};
// 是否启用思维链
let enable_thinking = model.ends_with("-thinking")
|| model == "gemini-2.5-pro"
|| model.starts_with("gemini-3-pro-")
|| model == "rev19-uic3-1p"
|| model == "gpt-oss-120b-medium";
// 生成请求 ID 和会话 ID
let request_id = format!("agent-{}", uuid::Uuid::new_v4());
let session_id = {
let uuid = uuid::Uuid::new_v4();
let bytes = uuid.as_bytes();
let n: u64 = u64::from_le_bytes([
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
]) % 9_000_000_000_000_000_000;
format!("-{}", n)
};
// 构建内部请求
let mut inner_request = request.clone();
// 添加会话 ID
inner_request["sessionId"] = serde_json::json!(session_id);
// 确保有 generationConfig
if inner_request.get("generationConfig").is_none() {
inner_request["generationConfig"] = serde_json::json!({
"temperature": 1.0,
"maxOutputTokens": 8096,
"topP": 0.85,
"topK": 50,
"candidateCount": 1,
"stopSequences": [
"<|user|>",
"<|bot|>",
"<|context_request|>",
"<|endoftext|>",
"<|end_of_turn|>"
],
"thinkingConfig": {
"includeThoughts": enable_thinking,
"thinkingBudget": if enable_thinking { 1024 } else { 0 }
}
});
} else {
// 确保有 thinkingConfig
if inner_request["generationConfig"]
.get("thinkingConfig")
.is_none()
{
inner_request["generationConfig"]["thinkingConfig"] = serde_json::json!({
"includeThoughts": enable_thinking,
"thinkingBudget": if enable_thinking { 1024 } else { 0 }
});
}
}
// 删除安全设置(Antigravity 不支持)
if let Some(obj) = inner_request.as_object_mut() {
obj.remove("safetySettings");
}
// 构建完整的 Antigravity 请求体
serde_json::json!({
"project": project_id,
"requestId": request_id,
"request": inner_request,
"model": actual_model,
"userAgent": "antigravity"
})
}
/// CodeWhisperer 响应解析结果
#[derive(Debug, Default)]
struct CWParsedResponse {
@@ -3016,7 +3347,7 @@ async fn chat_completions_internal(state: &AppState, request: &ChatCompletionReq
}
}
use crate::models::provider_pool_model::{CredentialData, ProviderCredential};
use crate::models::provider_pool_model::ProviderCredential;
/// 根据凭证调用 Provider (Anthropic 格式)
async fn call_provider_anthropic(
@@ -3304,9 +3635,12 @@ async fn call_provider_anthropic(
tracing::warn!("[Antigravity] Failed to discover project: {}", e);
}
// 获取 project_id 用于请求
let proj_id = antigravity.project_id.clone().unwrap_or_default();
// 先转换为 OpenAI 格式,再转换为 Antigravity 格式
let openai_request = convert_anthropic_to_openai(request);
let antigravity_request = convert_openai_to_antigravity(&openai_request);
let antigravity_request = convert_openai_to_antigravity_with_context(&openai_request, &proj_id);
match antigravity
.generate_content(&request.model, &antigravity_request)
@@ -3810,8 +4144,11 @@ async fn call_provider_openai(
tracing::warn!("[Antigravity] Failed to discover project: {}", e);
}
// 获取 project_id 用于请求
let proj_id = antigravity.project_id.clone().unwrap_or_default();
// 转换请求格式
let antigravity_request = convert_openai_to_antigravity(request);
let antigravity_request = convert_openai_to_antigravity_with_context(request, &proj_id);
match antigravity.generate_content(&request.model, &antigravity_request).await {
Ok(resp) => {
@@ -4595,7 +4932,8 @@ async fn call_provider_openai_for_ws(
}
}
CredentialData::AntigravityOAuth {
creds_file_path, ..
creds_file_path,
project_id,
} => {
let mut antigravity = AntigravityProvider::new();
if let Err(e) = antigravity
@@ -4623,7 +4961,14 @@ async fn call_provider_openai_for_ws(
return Err(e.to_string());
}
}
let antigravity_request = convert_openai_to_antigravity(request);
// 设置项目 ID
if let Some(pid) = project_id {
antigravity.project_id = Some(pid.clone());
}
let proj_id = antigravity.project_id.clone().unwrap_or_default();
let antigravity_request = convert_openai_to_antigravity_with_context(request, &proj_id);
match antigravity
.call_api("generateContent", &antigravity_request)
.await
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ProxyCast",
"version": "0.14.6",
"version": "0.14.8",
"identifier": "com.proxycast.app",
"build": {
"beforeDevCommand": "npm run dev",
+66 -2
View File
@@ -239,6 +239,45 @@ export function ApiServerPage() {
: "http://localhost:3001";
const apiKey = config?.server.api_key || "proxycast-key";
// 根据 Provider 类型获取测试模型
const getTestModel = (provider: string): string => {
switch (provider) {
case "antigravity":
return "gemini-3-pro-preview";
case "gemini":
return "gemini-2.0-flash";
case "qwen":
return "qwen-max";
case "openai":
return "gpt-4o";
case "claude":
return "claude-sonnet-4-20250514";
case "kiro":
default:
return "claude-opus-4-5-20251101";
}
};
const testModel = getTestModel(defaultProvider);
// 根据 Provider 类型获取 Gemini 测试模型
const getGeminiTestModel = (provider: string): string => {
switch (provider) {
case "antigravity":
return "gemini-3-pro-preview";
case "gemini":
return "gemini-2.0-flash";
default:
return "gemini-2.0-flash";
}
};
const geminiTestModel = getGeminiTestModel(defaultProvider);
// 是否显示 Gemini 测试端点
const showGeminiTest =
defaultProvider === "antigravity" || defaultProvider === "gemini";
// Test endpoints
const testEndpoints = [
{
@@ -264,7 +303,7 @@ export function ApiServerPage() {
path: "/v1/chat/completions",
needsAuth: true,
body: JSON.stringify({
model: "claude-opus-4-5-20251101",
model: testModel,
messages: [{ role: "user", content: "Say hi in one word" }],
}),
},
@@ -275,7 +314,7 @@ export function ApiServerPage() {
path: "/v1/messages",
needsAuth: true,
body: JSON.stringify({
model: "claude-opus-4-5-20251101",
model: testModel,
max_tokens: 100,
messages: [
{
@@ -285,6 +324,31 @@ export function ApiServerPage() {
],
}),
},
// Gemini 原生协议测试(仅在 Antigravity 或 Gemini Provider 时显示)
...(showGeminiTest
? [
{
id: "gemini",
name: "Gemini Generate",
method: "POST",
path: `/v1/gemini/${geminiTestModel}:generateContent`,
needsAuth: true,
body: JSON.stringify({
contents: [
{
role: "user",
parts: [
{ text: "What is 2+2? Answer with just the number." },
],
},
],
generationConfig: {
maxOutputTokens: 100,
},
}),
},
]
: []),
];
const runTest = async (endpoint: (typeof testEndpoints)[0]) => {
@@ -1,6 +1,7 @@
import { useState } from "react";
import { X, Key, FolderOpen } from "lucide-react";
import { useState, useEffect } from "react";
import { X, Key, FolderOpen, LogIn, Copy, Check, Loader2 } from "lucide-react";
import { open } from "@tauri-apps/plugin-dialog";
import { listen } from "@tauri-apps/api/event";
import { providerPoolApi, PoolProviderType } from "@/lib/api/providerPool";
interface AddCredentialModalProps {
@@ -14,7 +15,7 @@ const defaultCredsPath: Record<string, string> = {
kiro: "~/.aws/sso/cache/kiro-auth-token.json",
gemini: "~/.gemini/oauth_creds.json",
qwen: "~/.qwen/oauth_creds.json",
antigravity: "~/.antigravity/oauth_creds.json",
antigravity: "",
codex: "~/.codex/oauth.json",
claude_oauth: "~/.claude/oauth.json",
iflow: "~/.iflow/oauth_creds.json",
@@ -39,6 +40,11 @@ export function AddCredentialModal({
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
// Antigravity 添加方式: "login" | "file"
const [antigravityMode, setAntigravityMode] = useState<"login" | "file">(
"login",
);
const isOAuth = [
"kiro",
"gemini",
@@ -75,7 +81,91 @@ export function AddCredentialModal({
}
};
// Antigravity OAuth 登录状态
const [antigravityAuthUrl, setAntigravityAuthUrl] = useState<string | null>(
null,
);
const [urlCopied, setUrlCopied] = useState(false);
const [waitingForCallback, setWaitingForCallback] = useState(false);
// 监听后端发送的授权 URL 事件
useEffect(() => {
let unlisten: (() => void) | undefined;
const setupListener = async () => {
unlisten = await listen<{ auth_url: string }>(
"antigravity-auth-url",
(event) => {
setAntigravityAuthUrl(event.payload.auth_url);
},
);
};
setupListener();
return () => {
if (unlisten) {
unlisten();
}
};
}, []);
const handleAntigravityLogin = async () => {
setLoading(true);
setError(null);
setAntigravityAuthUrl(null);
try {
const trimmedName = name.trim() || undefined;
await providerPoolApi.startAntigravityOAuthLogin(trimmedName, false);
onSuccess();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
};
// 获取授权 URL 并启动服务器等待回调
const handleGetAntigravityAuthUrl = async () => {
setLoading(true);
setError(null);
setAntigravityAuthUrl(null);
setUrlCopied(false);
setWaitingForCallback(true);
try {
const trimmedName = name.trim() || undefined;
// 调用后端:启动服务器并等待回调
// 授权 URL 会通过事件发送
await providerPoolApi.getAntigravityAuthUrlAndWait(trimmedName, false);
// 如果成功返回,说明授权完成
onSuccess();
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
setError(errorMsg);
setWaitingForCallback(false);
} finally {
setLoading(false);
}
};
// 复制授权 URL
const handleCopyAuthUrl = () => {
if (antigravityAuthUrl) {
navigator.clipboard.writeText(antigravityAuthUrl);
setUrlCopied(true);
setTimeout(() => setUrlCopied(false), 2000);
}
};
const handleSubmit = async () => {
// Antigravity 登录模式单独处理
if (providerType === "antigravity" && antigravityMode === "login") {
await handleAntigravityLogin();
return;
}
setLoading(true);
setError(null);
@@ -151,6 +241,138 @@ export function AddCredentialModal({
}
};
// Antigravity 特殊渲染
const renderAntigravityContent = () => (
<>
{/* 模式选择 */}
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => setAntigravityMode("login")}
className={`flex-1 rounded-lg border px-3 py-2 text-sm ${
antigravityMode === "login"
? "border-primary bg-primary/10 text-primary"
: "hover:bg-muted"
}`}
>
<LogIn className="inline h-4 w-4 mr-1" />
Google 登录
</button>
<button
type="button"
onClick={() => setAntigravityMode("file")}
className={`flex-1 rounded-lg border px-3 py-2 text-sm ${
antigravityMode === "file"
? "border-primary bg-primary/10 text-primary"
: "hover:bg-muted"
}`}
>
<FolderOpen className="inline h-4 w-4 mr-1" />
导入文件
</button>
</div>
{antigravityMode === "login" ? (
<div className="space-y-4">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
<p className="text-sm text-blue-700 dark:text-blue-300">
点击下方按钮获取授权
URL,然后复制到浏览器(支持指纹浏览器)完成登录。
</p>
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
授权成功后,凭证将自动保存并添加到凭证池。
</p>
</div>
{/* 授权 URL 显示区域 */}
{antigravityAuthUrl && (
<div className="space-y-3">
<div className="rounded-lg border bg-muted/50 p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">授权 URL</span>
<button
type="button"
onClick={handleCopyAuthUrl}
className="flex items-center gap-1 rounded px-2 py-1 text-xs hover:bg-muted"
>
{urlCopied ? (
<>
<Check className="h-3 w-3 text-green-500" />
<span className="text-green-500">已复制</span>
</>
) : (
<>
<Copy className="h-3 w-3" />
<span>复制</span>
</>
)}
</button>
</div>
<p className="text-xs text-muted-foreground break-all font-mono">
{antigravityAuthUrl.slice(0, 100)}...
</p>
</div>
{waitingForCallback && (
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3 dark:border-yellow-800 dark:bg-yellow-950/30">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-yellow-600" />
<p className="text-sm text-yellow-700 dark:text-yellow-300">
请复制上方 URL 到浏览器完成登录,正在等待授权回调...
</p>
</div>
</div>
)}
</div>
)}
</div>
) : (
<>
{/* 文件选择 */}
<div>
<label className="mb-1 block text-sm font-medium">
凭证文件路径 <span className="text-red-500">*</span>
</label>
<div className="flex gap-2">
<input
type="text"
value={credsFilePath}
onChange={(e) => setCredsFilePath(e.target.value)}
placeholder="选择 accounts.json 或 oauth_creds.json..."
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm"
/>
<button
type="button"
onClick={handleSelectFile}
className="flex items-center gap-1 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
>
<FolderOpen className="h-4 w-4" />
浏览
</button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
支持 antigravity2api-nodejs 的 data/accounts.json 格式
</p>
</div>
{/* Project ID */}
<div>
<label className="mb-1 block text-sm font-medium">
Project ID (可选)
</label>
<input
type="text"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
placeholder="Google Cloud Project ID..."
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
/>
</div>
</>
)}
</>
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-background p-6 shadow-xl">
@@ -180,7 +402,9 @@ export function AddCredentialModal({
/>
</div>
{isOAuth ? (
{providerType === "antigravity" ? (
renderAntigravityContent()
) : isOAuth ? (
<>
{/* Credential File */}
<div>
@@ -211,8 +435,6 @@ export function AddCredentialModal({
"默认路径: ~/.gemini/oauth_creds.json"}
{providerType === "qwen" &&
"默认路径: ~/.qwen/oauth_creds.json"}
{providerType === "antigravity" &&
"默认路径: ~/.antigravity/oauth_creds.json"}
{providerType === "codex" && "默认路径: ~/.codex/oauth.json"}
{providerType === "claude_oauth" &&
"默认路径: ~/.claude/oauth.json"}
@@ -221,9 +443,8 @@ export function AddCredentialModal({
</p>
</div>
{/* Gemini/Antigravity specific: Project ID */}
{(providerType === "gemini" ||
providerType === "antigravity") && (
{/* Gemini specific: Project ID */}
{providerType === "gemini" && (
<div>
<label className="mb-1 block text-sm font-medium">
Project ID (可选)
@@ -296,13 +517,26 @@ export function AddCredentialModal({
>
取消
</button>
<button
onClick={handleSubmit}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "添加中..." : "添加凭证"}
</button>
{providerType === "antigravity" && antigravityMode === "login" ? (
// Antigravity 登录模式:显示获取授权 URL 按钮
!antigravityAuthUrl && (
<button
onClick={handleGetAntigravityAuthUrl}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "获取中..." : "获取授权 URL"}
</button>
)
) : (
<button
onClick={handleSubmit}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "添加中..." : "添加凭证"}
</button>
)}
</div>
</div>
</div>
+24
View File
@@ -359,6 +359,30 @@ export const providerPoolApi = {
return invoke("add_iflow_cookie_credential", { credsFilePath, name });
},
// Antigravity OAuth 登录(打开浏览器授权)
async startAntigravityOAuthLogin(
name?: string,
skipProjectIdFetch?: boolean,
): Promise<ProviderCredential> {
return invoke("start_antigravity_oauth_login", {
name,
skipProjectIdFetch,
});
},
// 获取 Antigravity OAuth 授权 URL 并等待回调(不自动打开浏览器)
// 服务器会在后台等待回调,成功后返回凭证
// 如果需要显示 URL,错误信息会包含 AUTH_URL: 前缀
async getAntigravityAuthUrlAndWait(
name?: string,
skipProjectIdFetch?: boolean,
): Promise<ProviderCredential> {
return invoke("get_antigravity_auth_url_and_wait", {
name,
skipProjectIdFetch,
});
},
// OAuth token management
async refreshCredentialToken(uuid: string): Promise<string> {
return invoke("refresh_pool_credential_token", { uuid });