diff --git a/package.json b/package.json index 196f9a248..2d4cf4af0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.44.0", + "version": "0.45.0", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 208665965..c2c0e298a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3947,7 +3947,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.44.0" +version = "0.45.0" dependencies = [ "anyhow", "arboard", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9ee261706..7da1a71fe 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxycast" -version = "0.44.0" +version = "0.45.0" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/resources/models/aliases/codex.json b/src-tauri/resources/models/aliases/codex.json new file mode 100644 index 000000000..a84554339 --- /dev/null +++ b/src-tauri/resources/models/aliases/codex.json @@ -0,0 +1,31 @@ +{ + "$schema": "../schema/alias.schema.json", + "provider": "codex", + "description": "OpenAI Codex CLI 支持的模型", + "models": [ + "gpt-5.2", + "gpt-5.1-codex-max", + "gpt-5.1-codex-mini" + ], + "aliases": { + "gpt-5.2": { + "actual": "gpt-5.2", + "internal_name": "gpt-5.2", + "provider": "openai", + "description": "最新前沿模型,跨知识、推理和编码的全面提升" + }, + "gpt-5.1-codex-max": { + "actual": "gpt-5.1-codex-max", + "internal_name": "gpt-5.1-codex-max", + "provider": "openai", + "description": "Codex 优化旗舰模型,深度且快速推理(默认)" + }, + "gpt-5.1-codex-mini": { + "actual": "gpt-5.1-codex-mini", + "internal_name": "gpt-5.1-codex-mini", + "provider": "openai", + "description": "Codex 优化轻量模型,更快更便宜但能力稍弱" + } + }, + "updated_at": "2026-01-13T00:00:00Z" +} diff --git a/src-tauri/resources/models/aliases/gemini.json b/src-tauri/resources/models/aliases/gemini.json new file mode 100644 index 000000000..93d028fca --- /dev/null +++ b/src-tauri/resources/models/aliases/gemini.json @@ -0,0 +1,38 @@ +{ + "$schema": "../schema/alias.schema.json", + "provider": "gemini", + "description": "Gemini CLI OAuth 服务的模型别名映射(基于 Cloud Code Assist)", + "models": [ + "gemini-3-pro-preview", + "gemini-3-flash-preview", + "gemini-flash-latest", + "gemini-pro-latest" + ], + "aliases": { + "gemini-3-pro-preview": { + "actual": "gemini-3-pro-preview", + "internal_name": "gemini-3-pro-preview", + "provider": "google", + "description": "Gemini 3 Pro 预览版" + }, + "gemini-3-flash-preview": { + "actual": "gemini-3-flash-preview", + "internal_name": "gemini-3-flash-preview", + "provider": "google", + "description": "Gemini 3 Flash 预览版" + }, + "gemini-flash-latest": { + "actual": "gemini-flash-latest", + "internal_name": "gemini-flash-latest", + "provider": "google", + "description": "Gemini Flash 最新版别名" + }, + "gemini-pro-latest": { + "actual": "gemini-pro-latest", + "internal_name": "gemini-pro-latest", + "provider": "google", + "description": "Gemini Pro 最新版别名" + } + }, + "updated_at": "2026-01-13T00:00:00Z" +} diff --git a/src-tauri/resources/models/index.json b/src-tauri/resources/models/index.json index bf8812a06..e65e22f6a 100644 --- a/src-tauri/resources/models/index.json +++ b/src-tauri/resources/models/index.json @@ -5,6 +5,7 @@ "abacus", "aihubmix", "alibaba", + "antigravity", "alibaba-cn", "amazon-bedrock", "anthropic", @@ -16,6 +17,7 @@ "chutes", "cloudflare-ai-gateway", "cloudflare-workers-ai", + "codex", "cohere", "cortecs", "deepinfra", @@ -78,7 +80,7 @@ "zhipuai", "zhipuai-coding-plan" ], - "total_models": 2028, + "total_models": 2039, "sources": { "models_dev": "https://models.dev/api.json", "manual": [] diff --git a/src-tauri/resources/models/providers/antigravity.json b/src-tauri/resources/models/providers/antigravity.json new file mode 100644 index 000000000..b7b2b6b01 --- /dev/null +++ b/src-tauri/resources/models/providers/antigravity.json @@ -0,0 +1,297 @@ +{ + "$schema": "../schema/model.schema.json", + "provider": { + "id": "antigravity", + "name": "Antigravity" + }, + "models": [ + { + "id": "gemini-3-pro-preview", + "name": "Gemini 3 Pro Preview", + "family": "gemini-pro", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 1000000, + "max_output": 64000 + }, + "status": "active", + "release_date": "2025-11-18", + "is_latest": true + }, + { + "id": "gemini-3-flash-preview", + "name": "Gemini 3 Flash Preview", + "family": "gemini-flash", + "tier": "mini", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 1048576, + "max_output": 65536 + }, + "status": "active", + "release_date": "2025-12-17", + "is_latest": true + }, + { + "id": "gemini-3-pro-image-preview", + "name": "Gemini 3 Pro Image", + "family": "gemini-pro-image", + "tier": "max", + "capabilities": { + "vision": true, + "tools": false, + "streaming": true, + "json_mode": false, + "function_calling": false, + "reasoning": false + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 32000, + "max_output": 8192 + }, + "status": "active", + "release_date": "2025-12-01", + "is_latest": true + }, + { + "id": "gemini-2.5-pro", + "name": "Gemini 2.5 Pro", + "family": "gemini-pro", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 1048576, + "max_output": 65536 + }, + "status": "active", + "release_date": "2025-03-20", + "is_latest": false + }, + { + "id": "gemini-2.5-flash", + "name": "Gemini 2.5 Flash", + "family": "gemini-flash", + "tier": "mini", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 1048576, + "max_output": 65536 + }, + "status": "active", + "release_date": "2025-03-20", + "is_latest": false + }, + { + "id": "gemini-2.5-flash-lite", + "name": "Gemini 2.5 Flash Lite", + "family": "gemini-flash-lite", + "tier": "mini", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 1048576, + "max_output": 65536 + }, + "status": "active", + "release_date": "2025-06-17", + "is_latest": false + }, + { + "id": "gemini-2.5-computer-use-preview-10-2025", + "name": "Gemini 2.5 Computer Use", + "family": "gemini-computer-use", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 128000, + "max_output": 8192 + }, + "status": "preview", + "release_date": "2025-10-01", + "is_latest": true + }, + { + "id": "gemini-2.0-flash", + "name": "Gemini 2.0 Flash", + "family": "gemini-flash", + "tier": "mini", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": false + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 1048576, + "max_output": 8192 + }, + "status": "active", + "release_date": "2024-12-11", + "is_latest": false + }, + { + "id": "gemini-claude-opus-4-5-thinking", + "name": "Claude Opus 4.5 Thinking", + "family": "claude-opus", + "tier": "max", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 200000, + "max_output": 32000 + }, + "status": "active", + "release_date": "2025-11-01", + "is_latest": true + }, + { + "id": "gemini-claude-sonnet-4-5-thinking", + "name": "Claude Sonnet 4.5 Thinking", + "family": "claude-sonnet", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 200000, + "max_output": 64000 + }, + "status": "active", + "release_date": "2025-09-29", + "is_latest": true + }, + { + "id": "gemini-claude-sonnet-4-5", + "name": "Claude Sonnet 4.5", + "family": "claude-sonnet", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": false + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 200000, + "max_output": 64000 + }, + "status": "active", + "release_date": "2025-09-29", + "is_latest": false + } + ], + "updated_at": "2026-01-12T00:00:00.000Z", + "source": "antigravity-manager" +} diff --git a/src-tauri/resources/models/providers/codex.json b/src-tauri/resources/models/providers/codex.json new file mode 100644 index 000000000..e45fd6a26 --- /dev/null +++ b/src-tauri/resources/models/providers/codex.json @@ -0,0 +1,89 @@ +{ + "$schema": "../schema/model.schema.json", + "provider": { + "id": "codex", + "name": "Codex" + }, + "models": [ + { + "id": "gpt-5.2", + "name": "GPT-5.2", + "family": "gpt-5", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 400000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2025-12-11", + "is_latest": true + }, + { + "id": "gpt-5.1-codex-max", + "name": "GPT-5.1 Codex Max", + "family": "gpt-5-codex", + "tier": "pro", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 400000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2025-11-13", + "is_latest": false + }, + { + "id": "gpt-5.1-codex-mini", + "name": "GPT-5.1 Codex Mini", + "family": "gpt-5-codex-mini", + "tier": "mini", + "capabilities": { + "vision": true, + "tools": true, + "streaming": true, + "json_mode": true, + "function_calling": true, + "reasoning": true + }, + "pricing": { + "input": 0, + "output": 0, + "currency": "USD" + }, + "limits": { + "context": 400000, + "max_output": 128000 + }, + "status": "active", + "release_date": "2025-11-13", + "is_latest": false + } + ], + "updated_at": "2026-01-13T00:00:00.000Z", + "source": "codex-cli" +} diff --git a/src-tauri/src/commands/models_cmd.rs b/src-tauri/src/commands/models_cmd.rs index 1447654e8..c3a19f646 100644 --- a/src-tauri/src/commands/models_cmd.rs +++ b/src-tauri/src/commands/models_cmd.rs @@ -2,6 +2,7 @@ //! //! 提供动态模型配置的 Tauri 命令 +use crate::commands::model_registry_cmd::ModelRegistryState; use crate::config::{save_config, ModelInfo, ModelsConfig, ProviderModelsConfig}; use crate::AppState; use serde::{Deserialize, Serialize}; @@ -58,28 +59,89 @@ pub struct SimpleProviderConfig { pub models: Vec, } +/// 需要使用别名配置的 Provider 列表 +const ALIAS_PROVIDERS: &[&str] = &["antigravity", "kiro", "codex", "gemini", "gemini_api_key"]; + +/// 别名配置文件名映射(某些 Provider 共享同一个别名配置) +fn get_alias_config_key(provider: &str) -> &str { + match provider { + "gemini_api_key" => "gemini", // Gemini API Key 使用 gemini 的别名配置 + _ => provider, + } +} + /// 获取所有 Provider 的简化配置(用于前端下拉框) +/// 对于别名 Provider(antigravity、kiro、codex、gemini、gemini_api_key),优先使用别名配置中的模型列表 #[tauri::command] pub async fn get_all_provider_models( app_state: State<'_, AppState>, + model_registry_state: State<'_, ModelRegistryState>, ) -> Result, String> { let state = app_state.read().await; + + // 获取别名配置 + let alias_configs = { + let guard = model_registry_state.read().await; + if let Some(service) = guard.as_ref() { + let configs = service.get_all_alias_configs().await; + tracing::info!( + "[get_all_provider_models] 加载了 {} 个别名配置: {:?}", + configs.len(), + configs.keys().collect::>() + ); + configs + } else { + tracing::warn!("[get_all_provider_models] ModelRegistryService 未初始化"); + HashMap::new() + } + }; + let result: HashMap = state .config .models .providers .iter() .map(|(key, value)| { - ( - key.clone(), - SimpleProviderConfig { - label: value.label.clone(), - models: value + // 对于别名 Provider,优先使用别名配置中的模型列表 + let models = if ALIAS_PROVIDERS.contains(&key.as_str()) { + // 使用映射获取实际的别名配置文件名 + let alias_config_key = get_alias_config_key(key); + if let Some(alias_config) = alias_configs.get(alias_config_key) { + tracing::info!( + "[get_all_provider_models] {} 使用别名配置 {}: {:?}", + key, + alias_config_key, + alias_config.models + ); + alias_config.models.clone() + } else { + tracing::warn!( + "[get_all_provider_models] {} 没有找到别名配置 {},使用用户配置", + key, + alias_config_key + ); + // 降级到用户配置 + value .models .iter() .filter(|m| m.enabled) .map(|m| m.id.clone()) - .collect(), + .collect() + } + } else { + value + .models + .iter() + .filter(|m| m.enabled) + .map(|m| m.id.clone()) + .collect() + }; + + ( + key.clone(), + SimpleProviderConfig { + label: value.label.clone(), + models, }, ) }) diff --git a/src-tauri/src/config/types.rs b/src-tauri/src/config/types.rs index 311b49416..7627202d9 100644 --- a/src-tauri/src/config/types.rs +++ b/src-tauri/src/config/types.rs @@ -1078,16 +1078,12 @@ impl Default for ModelsConfig { }, ); - // Codex + // Codex - 模型列表从别名配置动态加载 providers.insert( "codex".to_string(), ProviderModelsConfig { label: "Codex".to_string(), - models: vec![ModelInfo { - id: "codex-mini-latest".to_string(), - name: None, - enabled: true, - }], + models: vec![], // 从 aliases/codex.json 动态加载 }, ); diff --git a/src-tauri/src/providers/codex.rs b/src-tauri/src/providers/codex.rs index f858eb136..e64fd4750 100644 --- a/src-tauri/src/providers/codex.rs +++ b/src-tauri/src/providers/codex.rs @@ -597,8 +597,9 @@ impl CodexProvider { ("client_id", OPENAI_CLIENT_ID), ("response_type", "code"), ("redirect_uri", &self.get_redirect_uri()), - // 必须包含 api.responses.write 才能使用 responses API - ("scope", "openid email profile offline_access api.responses.write api.responses.read api.model.request"), + // 使用基础 scope,与 CLIProxyAPI 保持一致 + // chatgpt.com/backend-api/codex 端点不需要 api.responses.write 等额外权限 + ("scope", "openid email profile offline_access"), ("state", state), ("code_challenge", &pkce_codes.code_challenge), ("code_challenge_method", "S256"), @@ -751,8 +752,8 @@ impl CodexProvider { ("client_id", OPENAI_CLIENT_ID), ("grant_type", "refresh_token"), ("refresh_token", refresh_token.as_str()), - // 必须包含 api.responses.write 才能使用 responses API - ("scope", "openid email profile offline_access api.responses.write api.responses.read api.model.request"), + // 使用基础 scope,与 CLIProxyAPI 保持一致 + ("scope", "openid profile email"), ]; let resp = self @@ -1099,14 +1100,13 @@ impl CodexProvider { // Transform OpenAI chat completion request to Codex format let codex_request = transform_to_codex_format(request)?; - tracing::debug!("[CODEX] Calling API: {}", url); - let mut req = self .client .post(&url) .header("Authorization", format!("Bearer {}", token)) .header("Content-Type", "application/json") .header("Accept", "text/event-stream") + .header("Connection", "Keep-Alive") .header("Openai-Beta", "responses=experimental") .json(&codex_request); @@ -1130,12 +1130,15 @@ impl CodexProvider { ) .header("Originator", "codex_cli_rs") .header("Session_id", uuid::Uuid::new_v4().to_string()) - .header("Conversation_id", uuid::Uuid::new_v4().to_string()) - // Add account ID header if available - .header( - "Chatgpt-Account-Id", - self.credentials.account_id.as_deref().unwrap_or(""), - ); + .header("Conversation_id", uuid::Uuid::new_v4().to_string()); + + // 仅在 account_id 非空时添加 Chatgpt-Account-Id header + // 参考 CLIProxyAPI: 空值时不发送此 header + if let Some(account_id) = self.credentials.account_id.as_deref() { + if !account_id.trim().is_empty() { + req = req.header("Chatgpt-Account-Id", account_id); + } + } } let resp = req.send().await?; @@ -1226,17 +1229,167 @@ fn parse_jwt_claims(token: &str) -> (Option, Option) { (account_id, email) } +/// 根据模型名称获取对应的 Codex instructions +/// 参考 CLIProxyAPI: internal/misc/codex_instructions.go +fn get_codex_instructions_for_model(model_name: &str) -> &'static str { + let model_lower = model_name.to_lowercase(); + + if model_lower.contains("codex-max") { + // GPT-5.1 Codex Max 专用 prompt + CODEX_MAX_INSTRUCTIONS + } else if model_lower.contains("5.2-codex") { + // GPT-5.2 Codex 专用 prompt + CODEX_52_INSTRUCTIONS + } else if model_lower.contains("codex") { + // GPT-5 Codex 通用 prompt + CODEX_INSTRUCTIONS + } else if model_lower.contains("5.1") { + // GPT-5.1 通用 prompt + GPT_51_INSTRUCTIONS + } else if model_lower.contains("5.2") { + // GPT-5.2 通用 prompt + GPT_52_INSTRUCTIONS + } else { + // 默认使用 Codex prompt + CODEX_INSTRUCTIONS + } +} + +// GPT-5 Codex 通用 prompt(最新版本) +// 来源: CLIProxyAPI/internal/misc/codex_instructions/gpt_5_codex_prompt.md-009 +const CODEX_INSTRUCTIONS: &str = r#"You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer. + +## General + +- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.) + +## Editing constraints + +- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. +- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare. +- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). +- You may be in a dirty git worktree. + * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user. + * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes. + * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them. + * If the changes are in unrelated files, just ignore them and don't revert them. +- Do not amend a commit unless explicitly requested to do so. +- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed. +- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user. + +## Plan tool + +When using the planning tool: +- Skip using the planning tool for straightforward tasks (roughly the easiest 25%). +- Do not make single-step plans. +- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan. + +## Codex CLI harness, sandboxing, and approvals + +The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from. + +Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are: +- **read-only**: The sandbox only permits reading files. +- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. +- **danger-full-access**: No filesystem sandboxing - all commands are permitted. + +Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are: +- **restricted**: Requires approval +- **enabled**: No approval needed + +Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are +- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands. +- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox. +- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.) +- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding. + +When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval: +- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var) +- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files. +- You are running sandboxed and need to run a command that requires network access (e.g. installing packages) +- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command. +- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for +- (for all of these, you should weigh alternative paths that do not require approval) + +When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read. + +You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure. + +Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals. + +When requesting approval to execute a command that will require escalated privileges: + - Provide the `sandbox_permissions` parameter with the value `"require_escalated"` + - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter + +## Special user requests + +- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so. +- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps. + +## Presenting your work and final message + +You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. + +- Default: be very concise; friendly coding teammate tone. +- Ask only when needed; suggest ideas; mirror the user's style. +- For substantial work, summarize clearly; follow final-answer formatting. +- Skip heavy formatting for simple confirmations. +- Don't dump large files you've written; reference paths only. +- No "save/copy this file" - User is on the same machine. +- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something. +- For code changes: + * Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in. + * If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps. + * When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number. +- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result. + +### Final answer structure and style guidelines + +- Plain text; CLI handles styling. Use structure only when it helps scanability. +- Headers: optional; short Title Case (1-3 words) wrapped in **...**; no blank line before the first bullet; add only if they truly help. +- Bullets: use - ; merge related points; keep to one line when possible; 4-6 per list ordered by importance; keep phrasing consistent. +- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **. +- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible. +- Structure: group related bullets; order sections general -> specific -> supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task. +- Tone: collaborative, concise, factual; present tense, active voice; self-contained; no "above/below"; parallel wording. +- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short-wrap/reformat if long; avoid naming formatting styles in answers. +- Adaptation: code explanations -> precise, structured with code refs; simple tasks -> lead with outcome; big changes -> logical walkthrough + rationale + next actions; casual one-offs -> plain sentences, no headers/bullets. +- File References: When referencing files in your response, make sure to include the relevant start line and always follow the below rules: + * Use inline code to make file paths clickable. + * Each reference should have a stand alone path. Even if it's the same file. + * Accepted: absolute, workspace-relative, a/ or b/ diff prefixes, or bare filename/suffix. + * Line/column (1-based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1). + * Do not use URIs like file://, vscode://, or https://. + * Do not provide range of lines + * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5"#; + +// GPT-5.1 Codex Max 专用 prompt +// 来源: CLIProxyAPI/internal/misc/codex_instructions/gpt-5.1-codex-max_prompt.md-002 +const CODEX_MAX_INSTRUCTIONS: &str = CODEX_INSTRUCTIONS; + +// GPT-5.2 Codex 专用 prompt +// 来源: CLIProxyAPI/internal/misc/codex_instructions/gpt-5.2-codex_prompt.md-001 +const CODEX_52_INSTRUCTIONS: &str = CODEX_INSTRUCTIONS; + +// GPT-5.1 通用 prompt +// 来源: CLIProxyAPI/internal/misc/codex_instructions/gpt_5_1_prompt.md-004 +const GPT_51_INSTRUCTIONS: &str = CODEX_INSTRUCTIONS; + +// GPT-5.2 通用 prompt +// 来源: CLIProxyAPI/internal/misc/codex_instructions/gpt_5_2_prompt.md-001 +const GPT_52_INSTRUCTIONS: &str = CODEX_INSTRUCTIONS; + /// Transform OpenAI chat completion request to Codex format +/// 参考 CLIProxyAPI: internal/translator/codex/openai/chat-completions/codex_openai_request.go fn transform_to_codex_format( request: &serde_json::Value, ) -> Result> { let model = request["model"].as_str().unwrap_or("gpt-4o"); let messages = request["messages"].as_array(); - let stream = request["stream"].as_bool().unwrap_or(true); + // 注意:stream 参数被忽略,Codex API 强制要求 stream = true // Build input array from messages let mut input = Vec::new(); - let mut instructions = None; if let Some(msgs) = messages { for msg in msgs { @@ -1245,19 +1398,58 @@ fn transform_to_codex_format( match role { "system" => { - // System messages become instructions + // System messages 转换为 user message(Codex 使用 instructions 而不是 system role) if let Some(text) = content.as_str() { - instructions = Some(text.to_string()); + if !text.is_empty() { + input.push(serde_json::json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": text}] + })); + } } } - "user" | "assistant" => { + "user" => { let content_parts = if let Some(text) = content.as_str() { vec![serde_json::json!({"type": "input_text", "text": text})] + } else if let Some(arr) = content.as_array() { + arr.iter() + .filter_map(|part| { + let part_type = part["type"].as_str().unwrap_or(""); + match part_type { + "text" => part["text"].as_str().map( + |text| serde_json::json!({"type": "input_text", "text": text}), + ), + "image_url" => part["image_url"]["url"].as_str().map( + |url| serde_json::json!({"type": "input_image", "image_url": url}), + ), + _ => part["text"].as_str().map( + |text| serde_json::json!({"type": "input_text", "text": text}), + ), + } + }) + .collect() + } else { + vec![] + }; + + if !content_parts.is_empty() { + input.push(serde_json::json!({ + "type": "message", + "role": "user", + "content": content_parts + })); + } + } + "assistant" => { + // Assistant message content + let content_parts = if let Some(text) = content.as_str() { + vec![serde_json::json!({"type": "output_text", "text": text})] } else if let Some(arr) = content.as_array() { arr.iter() .filter_map(|part| { part["text"].as_str().map( - |text| serde_json::json!({"type": "input_text", "text": text}), + |text| serde_json::json!({"type": "output_text", "text": text}), ) }) .collect() @@ -1265,11 +1457,27 @@ fn transform_to_codex_format( vec![] }; - input.push(serde_json::json!({ - "type": "message", - "role": role, - "content": content_parts - })); + if !content_parts.is_empty() { + input.push(serde_json::json!({ + "type": "message", + "role": "assistant", + "content": content_parts + })); + } + + // Handle tool calls for assistant messages + if let Some(tool_calls) = msg["tool_calls"].as_array() { + for tc in tool_calls { + if tc["type"].as_str() == Some("function") { + input.push(serde_json::json!({ + "type": "function_call", + "call_id": tc["id"].as_str().unwrap_or(""), + "name": tc["function"]["name"].as_str().unwrap_or(""), + "arguments": tc["function"]["arguments"].as_str().unwrap_or("{}") + })); + } + } + } } "tool" => { // Tool results @@ -1286,51 +1494,107 @@ fn transform_to_codex_format( } } - // Build tools array if present - let tools = request["tools"].as_array().map(|tools| { - tools - .iter() - .map(|tool| { - let func = &tool["function"]; - serde_json::json!({ - "type": "function", - "name": func["name"], - "description": func["description"], - "parameters": func["parameters"] - }) - }) - .collect::>() - }); - - // Build the Codex request + // Build the Codex request - 参考 CLIProxyAPI 的必需字段 + // 注意:Codex API 要求 stream 必须为 true + // 参考 CLIProxyAPI: internal/runtime/executor/codex_executor.go 第 107 行 let mut codex_request = serde_json::json!({ "model": model, "input": input, - "stream": stream + "stream": true, // Codex API 强制要求 stream = true + "store": false, + "parallel_tool_calls": true, + "reasoning": { + "effort": "medium", + "summary": "auto" + }, + "include": ["reasoning.encrypted_content"] }); - if let Some(inst) = instructions { - codex_request["instructions"] = serde_json::json!(inst); + // 根据模型名称选择正确的 instructions + // 参考 CLIProxyAPI: internal/misc/codex_instructions.go + let instructions = get_codex_instructions_for_model(model); + codex_request["instructions"] = serde_json::json!(instructions); + + // Build tools array if present + if let Some(tools) = request["tools"].as_array() { + let codex_tools: Vec = tools + .iter() + .filter_map(|tool| { + let tool_type = tool["type"].as_str().unwrap_or(""); + if tool_type == "function" { + let func = &tool["function"]; + Some(serde_json::json!({ + "type": "function", + "name": func["name"], + "description": func["description"], + "parameters": func["parameters"] + })) + } else if !tool_type.is_empty() { + // Pass through built-in tools directly + Some(tool.clone()) + } else { + None + } + }) + .collect(); + + if !codex_tools.is_empty() { + codex_request["tools"] = serde_json::json!(codex_tools); + } } - if let Some(t) = tools { - codex_request["tools"] = serde_json::json!(t); + // Handle tool_choice + if let Some(tool_choice) = request.get("tool_choice") { + if let Some(tc_str) = tool_choice.as_str() { + codex_request["tool_choice"] = serde_json::json!(tc_str); + } else if tool_choice.is_object() { + let tc_type = tool_choice["type"].as_str().unwrap_or(""); + if tc_type == "function" { + codex_request["tool_choice"] = serde_json::json!({ + "type": "function", + "name": tool_choice["function"]["name"] + }); + } else if !tc_type.is_empty() { + codex_request["tool_choice"] = tool_choice.clone(); + } + } } - // Copy over other parameters - if let Some(temp) = request["temperature"].as_f64() { - codex_request["temperature"] = serde_json::json!(temp); - } - if let Some(max_tokens) = request["max_tokens"].as_i64() { - codex_request["max_output_tokens"] = serde_json::json!(max_tokens); - } - if let Some(top_p) = request["top_p"].as_f64() { - codex_request["top_p"] = serde_json::json!(top_p); + // Handle reasoning effort + if let Some(reasoning_effort) = request["reasoning_effort"].as_str() { + codex_request["reasoning"]["effort"] = serde_json::json!(reasoning_effort); } - // Handle reasoning effort for o1/o3/o4 models - if let Some(reasoning) = request.get("reasoning") { - codex_request["reasoning"] = reasoning.clone(); + // Handle response_format for Structured Outputs + if let Some(rf) = request.get("response_format") { + let rf_type = rf["type"].as_str().unwrap_or(""); + match rf_type { + "text" => { + codex_request["text"] = serde_json::json!({ + "format": {"type": "text"} + }); + } + "json_schema" => { + if let Some(js) = rf.get("json_schema") { + let mut format = serde_json::json!({ + "type": "json_schema" + }); + if let Some(name) = js["name"].as_str() { + format["name"] = serde_json::json!(name); + } + if let Some(strict) = js["strict"].as_bool() { + format["strict"] = serde_json::json!(strict); + } + if let Some(schema) = js.get("schema") { + format["schema"] = schema.clone(); + } + codex_request["text"] = serde_json::json!({ + "format": format + }); + } + } + _ => {} + } } Ok(codex_request) @@ -1633,11 +1897,17 @@ mod tests { assert_eq!(result["model"], "gpt-4o"); assert_eq!(result["stream"], true); - assert_eq!(result["instructions"], "You are a helpful assistant."); + // instructions 字段存在,使用 Codex 默认 prompt + assert!(result.get("instructions").is_some()); + // 验证 instructions 以正确的前缀开始 + let instructions = result["instructions"].as_str().unwrap(); + assert!(instructions.starts_with("You are Codex, based on GPT-5.")); let input = result["input"].as_array().unwrap(); - assert_eq!(input.len(), 1); // Only user message, system becomes instructions - assert_eq!(input[0]["role"], "user"); + // system message 被转换为 user message,所以有 2 条消息 + assert_eq!(input.len(), 2); + assert_eq!(input[0]["role"], "user"); // system -> user + assert_eq!(input[1]["role"], "user"); // original user } #[test] @@ -1796,8 +2066,8 @@ pub fn generate_codex_auth_url(state: &str, code_challenge: &str) -> String { ("client_id", OPENAI_CLIENT_ID), ("response_type", "code"), ("redirect_uri", redirect_uri.as_str()), - // 必须包含 api.responses.write 才能使用 responses API - ("scope", "openid email profile offline_access api.responses.write api.responses.read api.model.request"), + // 使用基础 scope,与 CLIProxyAPI 保持一致 + ("scope", "openid email profile offline_access"), ("state", state), ("code_challenge", code_challenge), ("code_challenge_method", "S256"), diff --git a/src-tauri/src/providers/gemini.rs b/src-tauri/src/providers/gemini.rs index ea2f3067c..3b898aefb 100644 --- a/src-tauri/src/providers/gemini.rs +++ b/src-tauri/src/providers/gemini.rs @@ -24,13 +24,20 @@ const CREDENTIALS_FILE: &str = "oauth_creds.json"; // OAuth 端点 const GEMINI_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; -// OAuth 凭证从环境变量读取 +// Gemini CLI OAuth 配置 - 与 CLIProxyAPI 对齐 +const DEFAULT_GEMINI_OAUTH_CLIENT_ID: &str = + "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"; +const DEFAULT_GEMINI_OAUTH_CLIENT_SECRET: &str = "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"; + +// OAuth 凭证 - 优先从环境变量读取,否则使用硬编码的默认值 fn get_oauth_client_id() -> String { - std::env::var("GEMINI_OAUTH_CLIENT_ID").unwrap_or_default() + std::env::var("GEMINI_OAUTH_CLIENT_ID") + .unwrap_or_else(|_| DEFAULT_GEMINI_OAUTH_CLIENT_ID.to_string()) } fn get_oauth_client_secret() -> String { - std::env::var("GEMINI_OAUTH_CLIENT_SECRET").unwrap_or_default() + std::env::var("GEMINI_OAUTH_CLIENT_SECRET") + .unwrap_or_else(|_| DEFAULT_GEMINI_OAUTH_CLIENT_SECRET.to_string()) } #[allow(dead_code)] diff --git a/src-tauri/src/server/handlers/provider_calls.rs b/src-tauri/src/server/handlers/provider_calls.rs index 1c2c24b97..c17500d42 100644 --- a/src-tauri/src/server/handlers/provider_calls.rs +++ b/src-tauri/src/server/handlers/provider_calls.rs @@ -59,8 +59,8 @@ use crate::models::anthropic::AnthropicMessagesRequest; use crate::models::openai::ChatCompletionRequest; use crate::models::provider_pool_model::{CredentialData, ProviderCredential}; use crate::providers::{ - AntigravityApiError, AntigravityProvider, ClaudeCustomProvider, IFlowProvider, KiroProvider, - OpenAICustomProvider, VertexProvider, + AntigravityApiError, AntigravityProvider, ClaudeCustomProvider, CodexProvider, IFlowProvider, + KiroProvider, OpenAICustomProvider, VertexProvider, }; use crate::server::AppState; use crate::server_utils::{ @@ -302,12 +302,18 @@ pub async fn call_provider_anthropic( } } } else { + let status_code = status.as_u16(); let body = resp.text().await.unwrap_or_default(); - let _ = state - .pool_service - .mark_unhealthy(db, &credential.uuid, Some(&body)); + eprintln!("[PROVIDER_CALL] Kiro 请求失败: status={} body={}", status_code, &body[..body.len().min(500)]); + // 只有 5xx 错误才标记为不健康 + if status_code >= 500 { + let _ = state + .pool_service + .mark_unhealthy(db, &credential.uuid, Some(&body)); + } + // 转发上游的实际状态码 ( - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::from_u16(status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), Json(serde_json::json!({"error": {"message": body}})), ) .into_response() @@ -460,7 +466,8 @@ pub async fn call_provider_anthropic( let openai_request = convert_anthropic_to_openai(request); match openai.call_api(&openai_request).await { Ok(resp) => { - if resp.status().is_success() { + let status = resp.status(); + if status.is_success() { match resp.text().await { Ok(body) => { // 记录原始响应以便调试 @@ -526,16 +533,22 @@ pub async fn call_provider_anthropic( } } } else { + let status_code = status.as_u16(); let body = resp.text().await.unwrap_or_default(); - if let Some(db) = &state.db { - let _ = state.pool_service.mark_unhealthy( - db, - &credential.uuid, - Some(&body), - ); + eprintln!("[PROVIDER_CALL] OpenAI 请求失败: status={} body={}", status_code, &body[..body.len().min(500)]); + // 只有 5xx 错误才标记为不健康,4xx 错误(如模型不支持)不应该标记凭证为不健康 + if status_code >= 500 { + if let Some(db) = &state.db { + let _ = state.pool_service.mark_unhealthy( + db, + &credential.uuid, + Some(&body), + ); + } } + // 转发上游的实际状态码 ( - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::from_u16(status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), Json(serde_json::json!({"error": {"message": body}})), ) .into_response() @@ -550,7 +563,7 @@ pub async fn call_provider_anthropic( ); } ( - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::BAD_GATEWAY, Json(serde_json::json!({"error": {"message": e.to_string()}})), ) .into_response() @@ -2062,9 +2075,205 @@ pub async fn call_provider_openai( } } } + // Codex OAuth 凭证处理 + CredentialData::CodexOAuth { + creds_file_path, + api_base_url, + } => { + // 加载 Codex 凭证 + let mut codex = CodexProvider::new(); + if let Err(e) = codex.load_credentials_from_path(creds_file_path).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": {"message": format!("Failed to load Codex credentials: {}", e)}})), + ) + .into_response(); + } + + // 如果配置了自定义 API Base URL,覆盖凭证文件中的配置 + if let Some(base_url) = api_base_url { + if !base_url.trim().is_empty() { + codex.credentials.api_base_url = Some(base_url.clone()); + } + } + + // 确保 token 有效 + if let Err(e) = codex.ensure_valid_token().await { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": {"message": format!("Codex token refresh failed: {}", e)}})), + ) + .into_response(); + } + + // 将 ChatCompletionRequest 转换为 serde_json::Value + let request_json = match serde_json::to_value(request) { + Ok(v) => v, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": {"message": format!("Failed to serialize request: {}", e)}})), + ) + .into_response(); + } + }; + + // 调用 Codex API + match codex.call_api(&request_json).await { + Ok(response) => { + let status = response.status(); + let headers = response.headers().clone(); + + // 检查是否为流式响应 + if request.stream { + // 流式响应:读取 Codex SSE 流,转换为 OpenAI SSE 格式 + // 参考 CLIProxyAPI: internal/translator/codex/openai/chat-completions/codex_openai_response.go + use std::sync::Arc; + use tokio::sync::Mutex; + + let bytes_stream = response.bytes_stream(); + + // 创建转换状态(包含缓冲区) + struct StreamState { + convert_state: CodexConvertState, + buffer: String, + } + + let state = Arc::new(Mutex::new(StreamState { + convert_state: CodexConvertState::default(), + buffer: String::new(), + })); + + let converted_stream = bytes_stream.map(move |result| { + let state = Arc::clone(&state); + async move { + match result { + Ok(bytes) => { + let chunk = String::from_utf8_lossy(&bytes); + let mut state = state.lock().await; + state.buffer.push_str(&chunk); + + let mut output = String::new(); + + // 处理缓冲区中的完整行 + while let Some(newline_pos) = state.buffer.find('\n') { + let line = state.buffer[..newline_pos].to_string(); + state.buffer = state.buffer[newline_pos + 1..].to_string(); + + if let Some(data) = line.strip_prefix("data: ") { + if let Ok(json) = serde_json::from_str::(data) { + if let Some(converted) = convert_codex_event_to_openai_sse_with_state( + &json, + &mut state.convert_state, + ) { + output.push_str(&format!("data: {}\n\n", converted)); + } + } + } + } + + Ok::<_, std::io::Error>(bytes::Bytes::from(output)) + } + Err(e) => { + tracing::error!("[Codex] Stream error: {}", e); + Err(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) + } + } + } + }).buffer_unordered(1).filter_map(|result| async move { + match result { + Ok(bytes) if !bytes.is_empty() => Some(Ok(bytes)), + Ok(_) => None, + Err(e) => Some(Err(e)), + } + }); + + let body = Body::from_stream(converted_stream); + let mut response_builder = Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "text/event-stream") + .header(header::CACHE_CONTROL, "no-cache") + .header(header::CONNECTION, "keep-alive"); + + for (key, value) in headers.iter() { + if key != header::CONTENT_TYPE + && key != header::TRANSFER_ENCODING + && key != header::CONTENT_LENGTH + { + response_builder = response_builder.header(key, value); + } + } + + response_builder.body(body).unwrap_or_else(|_| { + (StatusCode::INTERNAL_SERVER_ERROR, "Failed to build response") + .into_response() + }) + } else { + // 非流式响应:读取 SSE 流,解析 response.completed 事件,转换为 OpenAI 格式 + // 参考 CLIProxyAPI: internal/translator/codex/openai/chat-completions/codex_openai_response.go + match response.bytes().await { + Ok(body) => { + // 解析 SSE 数据,查找 response.completed 事件 + let body_str = String::from_utf8_lossy(&body); + let mut completed_data: Option = None; + + for line in body_str.lines() { + if let Some(data) = line.strip_prefix("data: ") { + if let Ok(json) = serde_json::from_str::(data) { + if json.get("type").and_then(|t| t.as_str()) == Some("response.completed") { + completed_data = Some(json); + break; + } + } + } + } + + match completed_data { + Some(codex_response) => { + // 转换为 OpenAI Chat Completions 格式 + let openai_response = convert_codex_to_openai_non_stream(&codex_response); + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(openai_response.to_string())) + .unwrap_or_else(|_| { + (StatusCode::INTERNAL_SERVER_ERROR, "Failed to build response") + .into_response() + }) + } + None => { + tracing::error!("[Codex] No response.completed event found in SSE stream"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": {"message": "No response.completed event found in Codex response"}})), + ) + .into_response() + } + } + } + Err(e) => { + tracing::error!("[Codex] Failed to read response body: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": {"message": format!("Failed to read Codex response: {}", e)}})), + ) + .into_response() + } + } + } + } + Err(e) => { + tracing::error!("[Codex] API call failed: {}", e); + ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({"error": {"message": format!("Codex API call failed: {}", e)}})), + ) + .into_response() + } + } + } // 新增的凭证类型暂不支持 OpenAI 格式 - CredentialData::CodexOAuth { .. } - | CredentialData::ClaudeOAuth { .. } => { + CredentialData::ClaudeOAuth { .. } => { ( StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": {"message": "This credential type does not support OpenAI format yet"}})), @@ -3456,3 +3665,319 @@ fn convert_openai_response_to_anthropic( } }) } + +/// 将 Codex response.completed 事件转换为 OpenAI Chat Completions 非流式响应格式 +/// 参考 CLIProxyAPI: internal/translator/codex/openai/chat-completions/codex_openai_response.go +fn convert_codex_to_openai_non_stream(codex_response: &serde_json::Value) -> serde_json::Value { + let response = &codex_response["response"]; + + // 提取基本信息 + let id = response["id"].as_str().unwrap_or("").to_string(); + let model = response["model"].as_str().unwrap_or("gpt-5").to_string(); + let created = response["created_at"].as_i64().unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) + }); + + // 提取 usage 信息 + let usage = &response["usage"]; + let prompt_tokens = usage["input_tokens"].as_i64().unwrap_or(0); + let completion_tokens = usage["output_tokens"].as_i64().unwrap_or(0); + let total_tokens = usage["total_tokens"] + .as_i64() + .unwrap_or(prompt_tokens + completion_tokens); + let reasoning_tokens = usage["output_tokens_details"]["reasoning_tokens"].as_i64(); + + // 处理 output 数组,提取 content、reasoning_content 和 tool_calls + let mut content_text: Option = None; + let mut reasoning_text: Option = None; + let mut tool_calls: Vec = Vec::new(); + + if let Some(output_array) = response["output"].as_array() { + for output_item in output_array { + let output_type = output_item["type"].as_str().unwrap_or(""); + + match output_type { + "reasoning" => { + // 提取 reasoning content from summary + if let Some(summary_array) = output_item["summary"].as_array() { + for summary_item in summary_array { + if summary_item["type"].as_str() == Some("summary_text") { + reasoning_text = + summary_item["text"].as_str().map(|s| s.to_string()); + break; + } + } + } + } + "message" => { + // 提取 message content + if let Some(content_array) = output_item["content"].as_array() { + for content_item in content_array { + if content_item["type"].as_str() == Some("output_text") { + content_text = content_item["text"].as_str().map(|s| s.to_string()); + break; + } + } + } + } + "function_call" => { + // 处理 function call + let call_id = output_item["call_id"].as_str().unwrap_or("").to_string(); + let name = output_item["name"].as_str().unwrap_or("").to_string(); + let arguments = output_item["arguments"] + .as_str() + .unwrap_or("{}") + .to_string(); + + tool_calls.push(serde_json::json!({ + "id": call_id, + "type": "function", + "function": { + "name": name, + "arguments": arguments + } + })); + } + _ => {} + } + } + } + + // 确定 finish_reason + let finish_reason = if !tool_calls.is_empty() { + "tool_calls" + } else { + "stop" + }; + + // 构建 message 对象 + let mut message = serde_json::json!({ + "role": "assistant" + }); + + if let Some(content) = content_text { + message["content"] = serde_json::json!(content); + } else { + message["content"] = serde_json::Value::Null; + } + + if let Some(reasoning) = reasoning_text { + message["reasoning_content"] = serde_json::json!(reasoning); + } + + if !tool_calls.is_empty() { + message["tool_calls"] = serde_json::json!(tool_calls); + } + + // 构建 usage 对象 + let mut usage_obj = serde_json::json!({ + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens + }); + + if let Some(reasoning) = reasoning_tokens { + usage_obj["completion_tokens_details"] = serde_json::json!({ + "reasoning_tokens": reasoning + }); + } + + // 构建完整响应 + serde_json::json!({ + "id": id, + "object": "chat.completion", + "created": created, + "model": model, + "choices": [{ + "index": 0, + "message": message, + "finish_reason": finish_reason, + "native_finish_reason": finish_reason + }], + "usage": usage_obj + }) +} + +/// Codex SSE 转换状态 +#[derive(Default)] +struct CodexConvertState { + response_id: String, + created_at: i64, + model: String, + function_call_index: i32, +} + +/// 将单个 Codex SSE 事件转换为 OpenAI SSE 格式(使用状态结构体) +/// 参考 CLIProxyAPI: internal/translator/codex/openai/chat-completions/codex_openai_response.go +fn convert_codex_event_to_openai_sse_with_state( + codex_event: &serde_json::Value, + state: &mut CodexConvertState, +) -> Option { + convert_codex_event_to_openai_sse( + codex_event, + &mut state.response_id, + &mut state.created_at, + &mut state.model, + &mut state.function_call_index, + ) +} + +/// 将单个 Codex SSE 事件转换为 OpenAI SSE 格式 +/// 参考 CLIProxyAPI: internal/translator/codex/openai/chat-completions/codex_openai_response.go +fn convert_codex_event_to_openai_sse( + codex_event: &serde_json::Value, + response_id: &mut String, + created_at: &mut i64, + model: &mut String, + function_call_index: &mut i32, +) -> Option { + let event_type = codex_event.get("type")?.as_str()?; + + match event_type { + "response.created" => { + // 保存响应元数据 + *response_id = codex_event["response"]["id"] + .as_str() + .unwrap_or("") + .to_string(); + *created_at = codex_event["response"]["created_at"].as_i64().unwrap_or(0); + *model = codex_event["response"]["model"] + .as_str() + .unwrap_or("gpt-5") + .to_string(); + None + } + "response.output_text.delta" => { + // 文本增量 + let delta = codex_event.get("delta")?.as_str()?; + let chunk = serde_json::json!({ + "id": response_id, + "object": "chat.completion.chunk", + "created": created_at, + "model": model, + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "content": delta + }, + "finish_reason": null + }] + }); + Some(chunk.to_string()) + } + "response.reasoning_summary_text.delta" => { + // 推理内容增量 + let delta = codex_event.get("delta")?.as_str()?; + let chunk = serde_json::json!({ + "id": response_id, + "object": "chat.completion.chunk", + "created": created_at, + "model": model, + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "reasoning_content": delta + }, + "finish_reason": null + }] + }); + Some(chunk.to_string()) + } + "response.reasoning_summary_text.done" => { + // 推理内容结束,添加换行 + let chunk = serde_json::json!({ + "id": response_id, + "object": "chat.completion.chunk", + "created": created_at, + "model": model, + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "reasoning_content": "\n\n" + }, + "finish_reason": null + }] + }); + Some(chunk.to_string()) + } + "response.output_item.done" => { + // 处理 function_call 完成事件 + let item = codex_event.get("item")?; + if item.get("type")?.as_str()? != "function_call" { + return None; + } + + *function_call_index += 1; + + let call_id = item["call_id"].as_str().unwrap_or("").to_string(); + let name = item["name"].as_str().unwrap_or("").to_string(); + let arguments = item["arguments"].as_str().unwrap_or("{}").to_string(); + + let chunk = serde_json::json!({ + "id": response_id, + "object": "chat.completion.chunk", + "created": created_at, + "model": model, + "choices": [{ + "index": 0, + "delta": { + "role": "assistant", + "tool_calls": [{ + "index": function_call_index, + "id": call_id, + "type": "function", + "function": { + "name": name, + "arguments": arguments + } + }] + }, + "finish_reason": null + }] + }); + Some(chunk.to_string()) + } + "response.completed" => { + // 响应完成 + let finish_reason = if *function_call_index != -1 { + "tool_calls" + } else { + "stop" + }; + + // 提取 usage 信息 + let usage = &codex_event["response"]["usage"]; + let prompt_tokens = usage["input_tokens"].as_i64().unwrap_or(0); + let completion_tokens = usage["output_tokens"].as_i64().unwrap_or(0); + let total_tokens = usage["total_tokens"] + .as_i64() + .unwrap_or(prompt_tokens + completion_tokens); + + let chunk = serde_json::json!({ + "id": response_id, + "object": "chat.completion.chunk", + "created": created_at, + "model": model, + "choices": [{ + "index": 0, + "delta": {}, + "finish_reason": finish_reason, + "native_finish_reason": finish_reason + }], + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens + } + }); + Some(chunk.to_string()) + } + _ => None, + } +} diff --git a/src-tauri/src/server/mod.rs b/src-tauri/src/server/mod.rs index 2a5ad8ea4..cc240b01b 100644 --- a/src-tauri/src/server/mod.rs +++ b/src-tauri/src/server/mod.rs @@ -26,8 +26,8 @@ use crate::providers::openai_custom::OpenAICustomProvider; use crate::providers::qwen::QwenProvider; use crate::server_utils::{ build_anthropic_response, build_anthropic_stream_response, build_error_response, - build_error_response_with_status, build_gemini_native_request, health, models, - parse_cw_response, + build_error_response_with_status, build_gemini_cli_request, build_gemini_native_request, + health, models, parse_cw_response, }; use crate::services::kiro_event_service::KiroEventService; use crate::services::provider_pool_service::ProviderPoolService; @@ -1348,11 +1348,136 @@ async fn gemini_generate_content( } } } + CredentialData::GeminiOAuth { + creds_file_path, + project_id, + } => { + // 使用 GeminiProvider 处理 Gemini CLI OAuth 凭证 + let mut gemini = GeminiProvider::new(); + if let Err(e) = gemini.load_credentials_from_path(creds_file_path).await { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": { + "message": format!("加载 Gemini 凭证失败: {}", e) + } + })), + ) + .into_response(); + } + + // 检查并刷新 Token + if !gemini.is_token_valid() { + tracing::info!("[Gemini CLI] Token 需要刷新,开始刷新..."); + match gemini.refresh_token_with_retry(3).await { + Ok(new_token) => { + tracing::info!( + "[Gemini CLI] Token 刷新成功,新 token 长度: {}", + new_token.len() + ); + } + Err(refresh_error) => { + tracing::error!("[Gemini CLI] Token 刷新失败: {:?}", refresh_error); + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": { + "message": format!("Token 刷新失败: {}", refresh_error) + } + })), + ) + .into_response(); + } + } + } + + // 设置项目 ID + if let Some(pid) = project_id { + gemini.project_id = Some(pid.clone()); + } else if gemini.project_id.is_none() { + // 尝试从 API 获取项目 ID + if let Err(e) = gemini.discover_project().await { + tracing::warn!("[Gemini CLI] 获取项目 ID 失败: {},使用随机生成的 ID", e); + 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(); + gemini.project_id = Some(format!("{}-{}-{}", adj, noun, random_part)); + } + } + + let proj_id = gemini.project_id.clone().unwrap_or_else(|| { + let uuid = uuid::Uuid::new_v4(); + format!("proxycast-{}", &uuid.to_string()[..8]) + }); + + state.logs.write().await.add( + "debug", + &format!("[GEMINI CLI] 使用 project_id: {}", proj_id), + ); + + // 构建 Gemini CLI 请求体 + // Gemini CLI 使用 Cloud Code Assist 端点,不做模型名称映射 + let gemini_request = build_gemini_cli_request(&request, model, &proj_id); + + state.logs.write().await.add( + "debug", + &format!( + "[GEMINI CLI] 请求体: {}", + serde_json::to_string(&gemini_request).unwrap_or_default() + ), + ); + + if is_stream { + // 流式响应 - 暂不支持 + return ( + StatusCode::NOT_IMPLEMENTED, + Json(serde_json::json!({ + "error": { + "message": "Gemini CLI 流式响应暂不支持,请使用 generateContent" + } + })), + ) + .into_response(); + } + + // 非流式响应 + match gemini.call_api("generateContent", &gemini_request).await { + Ok(resp) => { + state.logs.write().await.add( + "info", + &format!( + "[GEMINI CLI] 响应成功: {}", + serde_json::to_string(&resp) + .unwrap_or_default() + .chars() + .take(200) + .collect::() + ), + ); + + // 直接返回 Gemini 格式响应 + Json(resp).into_response() + } + Err(api_err) => { + state + .logs + .write() + .await + .add("error", &format!("[GEMINI CLI] 请求失败: {}", api_err)); + + build_error_response(&api_err.to_string()) + } + } + } _ => ( StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": { - "message": "Gemini 原生协议只支持 Antigravity 凭证" + "message": "Gemini 原生协议只支持 Antigravity 或 Gemini CLI OAuth 凭证" } })), ) diff --git a/src-tauri/src/server_utils.rs b/src-tauri/src/server_utils.rs index 60edde852..a81bdd004 100644 --- a/src-tauri/src/server_utils.rs +++ b/src-tauri/src/server_utils.rs @@ -606,6 +606,63 @@ pub fn build_anthropic_stream_response(model: &str, parsed: &CWParsedResponse) - }) } +/// 构建 Gemini CLI OAuth 请求体 +/// +/// 用于 Gemini OAuth 凭证(Cloud Code Assist API) +/// 不做模型名称映射,直接使用用户传入的模型名称 +pub fn build_gemini_cli_request( + request: &serde_json::Value, + model: &str, + project_id: &str, +) -> serde_json::Value { + // 是否启用思维链 + let enable_thinking = model.ends_with("-thinking") + || model == "gemini-2.5-pro" + || model.starts_with("gemini-3-pro-"); + + // 构建内部请求 + let mut inner_request = request.clone(); + + // 确保有 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, + "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 } + }); + } + } + + // 删除安全设置(Cloud Code Assist 不支持) + if let Some(obj) = inner_request.as_object_mut() { + obj.remove("safetySettings"); + } + + // 构建完整的 Gemini CLI 请求体 + // 格式与 CLIProxyAPI 对齐 + serde_json::json!({ + "project": project_id, + "model": model, // 直接使用模型名称,不做映射 + "request": inner_request + }) +} + /// 构建 Gemini 原生请求体 /// /// 将用户传入的 Gemini 格式请求转换为 Antigravity 请求格式 diff --git a/src-tauri/src/services/model_registry_service.rs b/src-tauri/src/services/model_registry_service.rs index 071292364..78cdf9235 100644 --- a/src-tauri/src/services/model_registry_service.rs +++ b/src-tauri/src/services/model_registry_service.rs @@ -286,7 +286,7 @@ impl ModelRegistryService { // 3. 加载别名配置 let mut aliases = HashMap::new(); let aliases_dir = models_dir.join("aliases"); - let alias_files = ["kiro", "antigravity"]; + let alias_files = ["kiro", "antigravity", "codex", "gemini"]; for alias_name in alias_files { let alias_file = aliases_dir.join(format!("{}.json", alias_name)); diff --git a/src-tauri/src/services/model_service.rs b/src-tauri/src/services/model_service.rs index 9b9c354c6..e8f97224b 100644 --- a/src-tauri/src/services/model_service.rs +++ b/src-tauri/src/services/model_service.rs @@ -352,10 +352,19 @@ impl ModelService { "claude-haiku-4-5".to_string(), ], PoolProviderType::Gemini => vec![ + // Gemini 3 系列 + "gemini-3-pro-preview".to_string(), + "gemini-3-flash-preview".to_string(), + // Gemini 2.5 系列 + "gemini-2.5-pro".to_string(), "gemini-2.5-flash".to_string(), "gemini-2.5-flash-lite".to_string(), - "gemini-2.5-pro".to_string(), - "gemini-2.5-pro-preview-06-05".to_string(), + // Gemini 2.0 系列 + "gemini-2.0-flash".to_string(), + "gemini-2.0-flash-lite".to_string(), + // Gemini 1.5 系列(已弃用但仍可用) + "gemini-1.5-pro".to_string(), + "gemini-1.5-flash".to_string(), ], PoolProviderType::Qwen => vec![ "qwen3-coder-plus".to_string(), diff --git a/src-tauri/src/services/provider_pool_service.rs b/src-tauri/src/services/provider_pool_service.rs index 97bea58d2..a443e373a 100644 --- a/src-tauri/src/services/provider_pool_service.rs +++ b/src-tauri/src/services/provider_pool_service.rs @@ -279,12 +279,25 @@ impl ProviderPoolService { .into_iter() .filter(|c| { let is_avail = c.is_available(); - eprintln!( - "[SELECT_CREDENTIAL] credential {} (type={}) is_available={}", - c.name.as_deref().unwrap_or("unnamed"), - c.provider_type, - is_avail - ); + if !is_avail { + eprintln!( + "[SELECT_CREDENTIAL] credential {} (type={}) is_available={} (is_healthy={}, is_disabled={}, error_count={}, last_error={:?})", + c.name.as_deref().unwrap_or("unnamed"), + c.provider_type, + is_avail, + c.is_healthy, + c.is_disabled, + c.error_count, + c.last_error_message + ); + } else { + eprintln!( + "[SELECT_CREDENTIAL] credential {} (type={}) is_available={}", + c.name.as_deref().unwrap_or("unnamed"), + c.provider_type, + is_avail + ); + } is_avail }) .collect(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b60718427..7271abaa8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.44.0", + "version": "0.45.0", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/agent/chat/components/ChatNavbar.tsx b/src/components/agent/chat/components/ChatNavbar.tsx index 3fdb71f07..0e28a069f 100644 --- a/src/components/agent/chat/components/ChatNavbar.tsx +++ b/src/components/agent/chat/components/ChatNavbar.tsx @@ -9,72 +9,10 @@ import { import { ScrollArea } from "@/components/ui/scroll-area"; import { Navbar } from "../styles"; import { cn } from "@/lib/utils"; -import { useProviderPool } from "@/hooks/useProviderPool"; -import { useApiKeyProvider } from "@/hooks/useApiKeyProvider"; -import { useModelRegistry } from "@/hooks/useModelRegistry"; import { getDefaultProvider } from "@/hooks/useTauri"; -import { getProviderAliasConfig } from "@/lib/api/modelRegistry"; -import type { ProviderAliasConfig } from "@/lib/types/modelRegistry"; - -// Provider type 到 registry ID 的映射(用于获取模型列表) -// 注意:antigravity 和 kiro 使用别名配置,需要单独处理 -const getRegistryIdFromType = (providerType: string): string => { - const typeMap: Record = { - openai: "openai", - anthropic: "anthropic", - "anthropic-compatible": "anthropic", // Anthropic 兼容格式使用 Anthropic 模型列表 - gemini: "google", - "azure-openai": "openai", - vertexai: "google", - ollama: "ollama", - kiro: "kiro", // 使用别名配置 - claude: "anthropic", - claude_oauth: "anthropic", - qwen: "alibaba", - codex: "openai", - antigravity: "antigravity", // 使用别名配置 - iflow: "openai", - gemini_api_key: "google", - }; - return typeMap[providerType.toLowerCase()] || providerType.toLowerCase(); -}; - -// 需要使用别名配置的 Provider 列表 -const ALIAS_PROVIDERS = ["antigravity", "kiro"]; - -// 生成 Provider 的显示标签 -const getProviderLabel = (providerType: string): string => { - const labelMap: Record = { - kiro: "Kiro", - gemini: "Gemini OAuth", - qwen: "通义千问", - antigravity: "Antigravity", - codex: "Codex", - claude_oauth: "Claude OAuth", - claude: "Claude", - openai: "OpenAI", - anthropic: "Anthropic", - "azure-openai": "Azure OpenAI", - vertexai: "VertexAI", - ollama: "Ollama", - gemini_api_key: "Gemini API Key", - iflow: "iFlow", - }; - // 如果在映射表中,使用映射;否则首字母大写 - return ( - labelMap[providerType.toLowerCase()] || - providerType.charAt(0).toUpperCase() + providerType.slice(1) - ); -}; - -/** 已配置的 Provider 信息 */ -interface ConfiguredProvider { - key: string; - label: string; - registryId: string; - fallbackRegistryId?: string; // 当 registryId 没有模型时的回退 - type: string; // 原始 provider type,用于确定 API 协议 -} +import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; +import { useProviderModels } from "@/hooks/useProviderModels"; +import { isAliasProvider } from "@/lib/constants/providerMappings"; interface ChatNavbarProps { providerType: string; @@ -105,14 +43,8 @@ export const ChatNavbar: React.FC = ({ // 用于防止无限循环 const hasInitialized = useRef(false); - // 别名配置缓存(用于 Antigravity/Kiro 等中转服务) - const [aliasConfig, setAliasConfig] = useState( - null, - ); - - // 获取凭证池数据 - const { overview: oauthCredentials } = useProviderPool(); - const { providers: apiKeyProviders } = useApiKeyProvider(); + // 获取已配置的 Provider 列表(使用共享 hook) + const { providers: configuredProviders } = useConfiguredProviders(); // 获取服务器默认 Provider useEffect(() => { @@ -127,123 +59,14 @@ export const ChatNavbar: React.FC = ({ loadDefaultProvider(); }, []); - // 获取模型注册表数据 - const { models: registryModels } = useModelRegistry({ autoLoad: true }); - - // 计算已配置的 Provider 列表(完全动态,无白名单限制) - const configuredProviders = useMemo(() => { - const providerMap = new Map(); - - // 从 OAuth 凭证提取 Provider(动态,支持所有类型) - oauthCredentials.forEach((overview) => { - if (overview.credentials.length > 0) { - const key = overview.provider_type; - if (!providerMap.has(key)) { - providerMap.set(key, { - key, - label: getProviderLabel(key), - registryId: getRegistryIdFromType(key), - type: key, - }); - } - } - }); - - // 从 API Key Provider 提取(动态,支持所有自定义 Provider) - // 使用 provider.id 作为 key,确保每个 Provider 单独显示 - // 特殊处理:如果与 OAuth 凭证冲突,使用带后缀的 key - apiKeyProviders - .filter((p) => p.api_key_count > 0 && p.enabled) - .forEach((provider) => { - let key = provider.id; - let label = provider.name; - - // 如果 key 与 OAuth 凭证冲突,添加 "_api_key" 后缀 - // 例如:Gemini OAuth 的 key 是 "gemini",Gemini API Key 的 key 变成 "gemini_api_key" - if (providerMap.has(key)) { - key = `${provider.id}_api_key`; - label = `${provider.name} API Key`; - } - - if (!providerMap.has(key)) { - // 优先使用 provider.id 作为 registryId(适用于系统预设的 Provider,如 deepseek, moonshot) - // 如果模型注册表中没有该 id 的模型,则回退到使用 type 映射(适用于自定义 Provider) - providerMap.set(key, { - key, - label, - registryId: provider.id, // 先尝试用 id - fallbackRegistryId: getRegistryIdFromType(provider.type), // 回退用 type - type: provider.type, - }); - } - }); - - return Array.from(providerMap.values()); - }, [oauthCredentials, apiKeyProviders]); - // 获取当前选中 Provider 的配置 const selectedProvider = useMemo(() => { return configuredProviders.find((p) => p.key === providerType); }, [configuredProviders, providerType]); - // 当选中别名 Provider 时,加载别名配置 - useEffect(() => { - if (selectedProvider && ALIAS_PROVIDERS.includes(selectedProvider.key)) { - getProviderAliasConfig(selectedProvider.key) - .then((config) => { - setAliasConfig(config); - }) - .catch((error) => { - console.error("加载别名配置失败:", error); - setAliasConfig(null); - }); - } else { - setAliasConfig(null); - } - }, [selectedProvider]); - - // 获取当前 Provider 的模型列表(从 model_registry 获取) - // 按照模型版本排序,最新的在前面 - const currentModels = useMemo(() => { - if (!selectedProvider) return []; - - // 对于别名 Provider(Antigravity、Kiro),使用别名配置中的模型列表 - if (ALIAS_PROVIDERS.includes(selectedProvider.key) && aliasConfig) { - return aliasConfig.models; - } - - // 从 model_registry 获取模型 - // 优先使用 registryId,如果没有模型则回退到 fallbackRegistryId - let models = registryModels.filter( - (m) => m.provider_id === selectedProvider.registryId, - ); - - // 如果没有找到模型,尝试使用 fallbackRegistryId - if (models.length === 0 && selectedProvider.fallbackRegistryId) { - models = registryModels.filter( - (m) => m.provider_id === selectedProvider.fallbackRegistryId, - ); - } - - // 按照 release_date 和 is_latest 排序 - const sortedModels = [...models].sort((a, b) => { - // 1. is_latest 优先 - if (a.is_latest && !b.is_latest) return -1; - if (!a.is_latest && b.is_latest) return 1; - - // 2. 按 release_date 降序(最新的在前) - if (a.release_date && b.release_date) { - return b.release_date.localeCompare(a.release_date); - } - if (a.release_date && !b.release_date) return -1; - if (!a.release_date && b.release_date) return 1; - - // 3. 按 display_name 字母序 - return a.display_name.localeCompare(b.display_name); - }); - - return sortedModels.map((m) => m.id); - }, [selectedProvider, registryModels, aliasConfig]); + // 获取当前 Provider 的模型列表(使用共享 hook) + const { modelIds: currentModels, loading: modelsLoading } = + useProviderModels(selectedProvider); // 初始化:优先选择服务器默认 Provider,否则选择第一个已配置的 useEffect(() => { @@ -283,11 +106,11 @@ export const ChatNavbar: React.FC = ({ modelRef.current = model; useEffect(() => { - // 对于别名 Provider,等待别名配置加载完成 + // 对于别名 Provider,等待模型加载完成 if ( selectedProvider && - ALIAS_PROVIDERS.includes(selectedProvider.key) && - !aliasConfig + isAliasProvider(selectedProvider.key) && + modelsLoading ) { return; } @@ -300,7 +123,7 @@ export const ChatNavbar: React.FC = ({ ) { setModel(currentModels[0]); } - }, [currentModels, setModel, selectedProvider, aliasConfig]); + }, [currentModels, setModel, selectedProvider, modelsLoading]); const selectedProviderLabel = selectedProvider?.label || providerType; diff --git a/src/components/agent/chat/hooks/useAgentChat.ts b/src/components/agent/chat/hooks/useAgentChat.ts index 690cb34ab..ea6c7cb9b 100644 --- a/src/components/agent/chat/hooks/useAgentChat.ts +++ b/src/components/agent/chat/hooks/useAgentChat.ts @@ -179,6 +179,8 @@ export function useAgentChat(options: UseAgentChatOptions = {}) { const loadConfig = async () => { try { const config = await getProviderConfig(); + console.log("[useAgentChat] 加载模型配置成功:", config); + console.log("[useAgentChat] codex 模型列表:", config.codex?.models); setProviderConfig(config); } catch (error) { console.warn("加载模型配置失败,使用默认配置:", error); diff --git a/src/components/agent/chat/types.ts b/src/components/agent/chat/types.ts index 196cf22dd..78bc9c9d2 100644 --- a/src/components/agent/chat/types.ts +++ b/src/components/agent/chat/types.ts @@ -144,7 +144,7 @@ export const PROVIDER_CONFIG: Record< }, codex: { label: "Codex", - models: ["codex-mini-latest"], + models: [], // 从后端别名配置动态加载 }, claude_oauth: { label: "Claude OAuth", diff --git a/src/components/api-server/ApiServerPage.tsx b/src/components/api-server/ApiServerPage.tsx index 8864ce94d..8590c5783 100644 --- a/src/components/api-server/ApiServerPage.tsx +++ b/src/components/api-server/ApiServerPage.tsx @@ -36,6 +36,7 @@ import { import { getModelRegistry, getModelsForProvider, + getProviderAliasConfig, } from "@/lib/api/modelRegistry"; import type { EnhancedModelMetadata } from "@/lib/types/modelRegistry"; @@ -93,11 +94,43 @@ const getProviderApiType = (provider: string): ApiType => { return "openai"; }; +// 需要使用别名配置的 Provider 列表 +const ALIAS_PROVIDERS = [ + "antigravity", + "kiro", + "codex", + "gemini", + "gemini_api_key", +]; + +// 别名配置文件名映射(某些 Provider 共享同一个别名配置) +const ALIAS_CONFIG_MAPPING: Record = { + gemini_api_key: "gemini", +}; + // 根据 API 类型获取对应的模型 provider_id 列表 -const getModelProviderIds = (apiType: ApiType): string[] => { +const getModelProviderIds = (apiType: ApiType, provider?: string): string[] => { + // Antigravity 使用自己的模型列表(别名配置) + if (provider?.toLowerCase() === "antigravity") { + return ["antigravity"]; + } + + // Codex 使用自己的模型列表(别名配置) + if (provider?.toLowerCase() === "codex") { + return ["codex"]; + } + + // Gemini 使用别名配置 + if ( + provider?.toLowerCase() === "gemini" || + provider?.toLowerCase() === "gemini_api_key" + ) { + return ["gemini"]; + } + switch (apiType) { case "gemini": - return ["google"]; + return ["gemini"]; // 使用别名配置 case "anthropic": return ["anthropic"]; case "openai": @@ -217,31 +250,84 @@ export function ApiServerPage() { let models: EnhancedModelMetadata[]; if (provider) { - // 首先检查是否是自定义 API Key Provider - // 如果是,使用其 type 字段来确定 API 类型 - let effectiveProvider = provider; - if (providers) { - const customProvider = providers.find((p) => p.id === provider); - if (customProvider) { - // 使用自定义 Provider 的 type 字段 - effectiveProvider = customProvider.type; + // 检查是否是别名 Provider + const providerLower = provider.toLowerCase(); + if (ALIAS_PROVIDERS.includes(providerLower)) { + // 使用别名配置 + const aliasConfigKey = + ALIAS_CONFIG_MAPPING[providerLower] || providerLower; + const aliasConfig = await getProviderAliasConfig(aliasConfigKey); + + if (aliasConfig && aliasConfig.models.length > 0) { + // 将别名配置中的模型转换为 EnhancedModelMetadata 格式 + models = aliasConfig.models.map( + (modelName): EnhancedModelMetadata => { + const aliasInfo = aliasConfig.aliases[modelName]; + return { + id: modelName, + display_name: modelName, + provider_id: providerLower, + provider_name: provider, + family: aliasInfo?.provider || null, + tier: "pro" as const, + capabilities: { + vision: false, + tools: true, + streaming: true, + json_mode: true, + function_calling: true, + reasoning: modelName.includes("thinking"), + }, + pricing: null, + limits: { + context_length: null, + max_output_tokens: null, + requests_per_minute: null, + tokens_per_minute: null, + }, + status: "active" as const, + release_date: null, + is_latest: false, + description: + aliasInfo?.description || + `${aliasInfo?.actual || modelName}`, + source: "custom" as const, + created_at: Date.now() / 1000, + updated_at: Date.now() / 1000, + }; + }, + ); + } else { + models = []; } - } - - // 根据 Provider 的 API 类型过滤模型 - const apiType = getProviderApiType(effectiveProvider); - const providerIds = getModelProviderIds(apiType); - - if (providerIds.length > 0) { - // 获取所有匹配 provider_id 的模型 - const modelPromises = providerIds.map((id) => - getModelsForProvider(id), - ); - const modelArrays = await Promise.all(modelPromises); - models = modelArrays.flat(); } else { - // 未知类型,显示所有模型 - models = await getModelRegistry(); + // 非别名 Provider,使用模型注册表 + // 首先检查是否是自定义 API Key Provider + // 如果是,使用其 type 字段来确定 API 类型 + let effectiveProvider = provider; + if (providers) { + const customProvider = providers.find((p) => p.id === provider); + if (customProvider) { + // 使用自定义 Provider 的 type 字段 + effectiveProvider = customProvider.type; + } + } + + // 根据 Provider 的 API 类型过滤模型 + const apiType = getProviderApiType(effectiveProvider); + const providerIds = getModelProviderIds(apiType, provider); + + if (providerIds.length > 0) { + // 获取所有匹配 provider_id 的模型 + const modelPromises = providerIds.map((id) => + getModelsForProvider(id), + ); + const modelArrays = await Promise.all(modelPromises); + models = modelArrays.flat(); + } else { + // 未知类型,显示所有模型 + models = await getModelRegistry(); + } } } else { models = await getModelRegistry(); @@ -490,10 +576,14 @@ export function ApiServerPage() { } }); - // 转换为数组并按凭证数量排序 - const providers = Array.from(providerMap.values()).sort( - (a, b) => b.totalCount - a.totalCount, - ); + // 转换为数组并按凭证数量排序,Qwen 排到最后 + const providers = Array.from(providerMap.values()).sort((a, b) => { + // Qwen 排到最后 + if (a.id === "qwen" && b.id !== "qwen") return 1; + if (b.id === "qwen" && a.id !== "qwen") return -1; + // 其他按凭证数量排序 + return b.totalCount - a.totalCount; + }); setAvailableProviders(providers); }; diff --git a/src/components/flow-monitor/ReplayDialog.tsx b/src/components/flow-monitor/ReplayDialog.tsx index 2142bc2d3..c6da84c7f 100644 --- a/src/components/flow-monitor/ReplayDialog.tsx +++ b/src/components/flow-monitor/ReplayDialog.tsx @@ -563,7 +563,9 @@ function ReplayResultDisplay({
- {t("total_duration", { duration: ((batchResult.total_duration_ms / 1000).toFixed(2)) + "s" })} + {t("total_duration", { + duration: (batchResult.total_duration_ms / 1000).toFixed(2) + "s", + })}
@@ -604,21 +606,27 @@ function ReplayResultDisplay({ singleResult.success ? "text-green-600" : "text-red-600", )} > - {singleResult.success ? t("success_with_message", { action: "重放" }) : t("error_with_message", { action: "重放" })} + {singleResult.success + ? t("success_with_message", { action: "重放" }) + : t("error_with_message", { action: "重放" })} {singleResult.success && singleResult.replay_flow_id && (
- {t("new_flow_id", { id: "" })} + + {t("new_flow_id", { id: "" })}{" "} + {singleResult.replay_flow_id.slice(0, 16)}...
- {t("duration", { duration: formatDuration(singleResult.duration_ms) })} + {t("duration", { + duration: formatDuration(singleResult.duration_ms), + })}
{onNavigateToFlow && ( ); }; @@ -246,152 +170,30 @@ export const ProviderModelSelector: React.FC = ({ const [selectedModelId, setSelectedModelId] = useState( initialModelId || null, ); - const [aliasConfig, setAliasConfig] = useState( - null, - ); - const [aliasLoading, setAliasLoading] = useState(false); - // 获取凭证池数据 - const { overview: oauthCredentials, loading: oauthLoading } = - useProviderPool(); - const { providers: apiKeyProviders, loading: apiKeyLoading } = - useApiKeyProvider(); + // 获取已配置的 Provider 列表(使用共享 hook) + const { providers: configuredProviders, loading: providersLoading } = + useConfiguredProviders(); - // 获取模型注册表数据 + // 获取当前选中的 Provider + const selectedProvider = useMemo(() => { + return configuredProviders.find((p) => p.key === selectedProviderId); + }, [configuredProviders, selectedProviderId]); + + // 获取模型列表(使用共享 hook,返回完整元数据) const { - models, + models: filteredModels, loading: modelsLoading, error: modelsError, - } = useModelRegistry({ - autoLoad: true, - }); - - // 当选中别名 Provider 时,加载别名配置 - useEffect(() => { - if (selectedProviderId && ALIAS_PROVIDERS.includes(selectedProviderId)) { - setAliasLoading(true); - getProviderAliasConfig(selectedProviderId) - .then((config) => { - setAliasConfig(config); - }) - .catch((error) => { - console.error("加载别名配置失败:", error); - setAliasConfig(null); - }) - .finally(() => { - setAliasLoading(false); - }); - } else { - setAliasConfig(null); - } - }, [selectedProviderId]); - - // 计算已配置的 Provider 列表 - const configuredProviders = useMemo(() => { - const providerMap = new Map(); - - // 从 OAuth 凭证提取 Provider - oauthCredentials.forEach((overview) => { - const registryId = CREDENTIAL_TYPE_TO_PROVIDER_ID[overview.provider_type]; - if (registryId && overview.credentials.length > 0) { - const existing = providerMap.get(registryId); - if (existing) { - existing.credentialCount += overview.credentials.length; - } else { - providerMap.set(registryId, { - id: registryId, - name: PROVIDER_DISPLAY_NAMES[registryId] || registryId, - registryId, - source: "oauth", - credentialCount: overview.credentials.length, - }); - } - } - }); - - // 从 API Key Provider 提取(只包含有 API Key 的) - apiKeyProviders - .filter((p) => p.api_key_count > 0 && p.enabled) - .forEach((provider) => { - const registryId = - PROVIDER_TYPE_TO_REGISTRY_ID[provider.type] || provider.type; - const existing = providerMap.get(registryId); - if (existing) { - existing.credentialCount += provider.api_key_count; - } else { - providerMap.set(registryId, { - id: registryId, - name: PROVIDER_DISPLAY_NAMES[registryId] || provider.name, - registryId, - source: "apikey", - credentialCount: provider.api_key_count, - }); - } - }); - - return Array.from(providerMap.values()).sort((a, b) => - a.name.localeCompare(b.name), - ); - }, [oauthCredentials, apiKeyProviders]); + } = useProviderModels(selectedProvider, { returnFullMetadata: true }); // 默认选中第一个 Provider useEffect(() => { if (!selectedProviderId && configuredProviders.length > 0) { - setSelectedProviderId(configuredProviders[0].registryId); + setSelectedProviderId(configuredProviders[0].key); } }, [selectedProviderId, configuredProviders]); - // 过滤当前 Provider 的模型 - const filteredModels = useMemo((): EnhancedModelMetadata[] => { - if (!selectedProviderId) return []; - - // 对于别名 Provider(Antigravity、Kiro),使用别名配置中的模型列表 - if (ALIAS_PROVIDERS.includes(selectedProviderId) && aliasConfig) { - // 将别名配置中的模型转换为 EnhancedModelMetadata 格式 - return aliasConfig.models.map((modelName): EnhancedModelMetadata => { - const aliasInfo = aliasConfig.aliases[modelName]; - return { - id: modelName, - display_name: modelName, - provider_id: selectedProviderId, - provider_name: - PROVIDER_DISPLAY_NAMES[selectedProviderId] || selectedProviderId, - family: aliasInfo?.provider || null, - tier: "pro" as const, - capabilities: { - vision: false, - tools: true, - streaming: true, - json_mode: true, - function_calling: true, - reasoning: modelName.includes("thinking"), - }, - pricing: null, - limits: { - context_length: null, - max_output_tokens: null, - requests_per_minute: null, - tokens_per_minute: null, - }, - status: "active" as const, - release_date: null, - is_latest: false, - description: - aliasInfo?.description || `${aliasInfo?.actual || modelName}`, - source: "custom" as const, - created_at: Date.now() / 1000, - updated_at: Date.now() / 1000, - }; - }); - } - - // 对于标准 Provider,从模型注册表过滤 - // 使用映射表将 UI Provider ID 转换为模型注册表 Provider ID - const registryProviderId = - PROVIDER_TO_REGISTRY_MAPPING[selectedProviderId] || selectedProviderId; - return models.filter((m) => m.provider_id === registryProviderId); - }, [models, selectedProviderId, aliasConfig]); - // 选择 Provider const handleSelectProvider = useCallback((providerId: string) => { setSelectedProviderId(providerId); @@ -409,7 +211,7 @@ export const ProviderModelSelector: React.FC = ({ [selectedProviderId, onSelect], ); - const isLoading = oauthLoading || apiKeyLoading || modelsLoading; + const isLoading = providersLoading || modelsLoading; // 空状态 if (!isLoading && configuredProviders.length === 0) { @@ -440,17 +242,17 @@ export const ProviderModelSelector: React.FC = ({

已配置凭证的

- {isLoading ? ( + {providersLoading ? (
) : ( configuredProviders.map((provider) => ( handleSelectProvider(provider.registryId)} + isSelected={selectedProviderId === provider.key} + onClick={() => handleSelectProvider(provider.key)} /> )) )} @@ -462,13 +264,13 @@ export const ProviderModelSelector: React.FC = ({

Models

- {selectedProviderId - ? `${PROVIDER_DISPLAY_NAMES[selectedProviderId] || selectedProviderId} 的模型` + {selectedProvider + ? `${getProviderLabel(selectedProvider.key)} 的模型` : "请选择 Provider"}

- {modelsLoading || aliasLoading ? ( + {modelsLoading ? (
diff --git a/src/components/provider-pool/api-key/providerTypeMapping.ts b/src/components/provider-pool/api-key/providerTypeMapping.ts index 00d5d7fd7..2a9df273d 100644 --- a/src/components/provider-pool/api-key/providerTypeMapping.ts +++ b/src/components/provider-pool/api-key/providerTypeMapping.ts @@ -20,7 +20,7 @@ const PROVIDER_ID_TO_REGISTRY_ID: Record = { // 主流 AI openai: "openai", anthropic: "anthropic", - gemini: "google", + gemini: "gemini", deepseek: "deepseek", moonshot: "moonshot", groq: "groq", @@ -45,6 +45,8 @@ const PROVIDER_ID_TO_REGISTRY_ID: Record = { "aws-bedrock": "anthropic", // 代理服务 iflow: "deepseek", // iFlow 是 DeepSeek 的代理 + antigravity: "antigravity", // Antigravity 使用自己的模型列表 + codex: "codex", // Codex 使用自己的模型列表 // 其他 ollama: "ollama", together: "together", @@ -61,7 +63,7 @@ const PROVIDER_TYPE_TO_REGISTRY_ID: Record = { "anthropic-compatible": "anthropic", // Anthropic 兼容格式 openai: "openai", "openai-response": "openai", - gemini: "google", + gemini: "gemini", }; /** diff --git a/src/components/terminal/ai/TerminalAIModeSelector.tsx b/src/components/terminal/ai/TerminalAIModeSelector.tsx index 2f6d3d81c..ae346b798 100644 --- a/src/components/terminal/ai/TerminalAIModeSelector.tsx +++ b/src/components/terminal/ai/TerminalAIModeSelector.tsx @@ -15,11 +15,9 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { useProviderPool } from "@/hooks/useProviderPool"; -import { useApiKeyProvider } from "@/hooks/useApiKeyProvider"; -import { useModelRegistry } from "@/hooks/useModelRegistry"; -import { getProviderAliasConfig } from "@/lib/api/modelRegistry"; -import type { ProviderAliasConfig } from "@/lib/types/modelRegistry"; +import { useConfiguredProviders } from "@/hooks/useConfiguredProviders"; +import { useProviderModels } from "@/hooks/useProviderModels"; +import { isAliasProvider } from "@/lib/constants/providerMappings"; // ============================================================================ // 常量 @@ -28,11 +26,12 @@ import type { ProviderAliasConfig } from "@/lib/types/modelRegistry"; /** * 从凭证类型提取支持的模型列表 * 对应后端 orchestrator_cmd.rs 中的 extract_supported_models 函数 + * 这是一个降级策略:优先使用凭证池中的模型列表 */ const extractSupportedModels = ( providerType: string, credentialType: string, - providerId?: string, // 添加 providerId 参数用于特殊判断 + providerId?: string, ): string[] => { const type = providerType.toLowerCase(); const credType = credentialType.toLowerCase(); @@ -110,9 +109,9 @@ const extractSupportedModels = ( ]; } - // Codex OAuth 凭证 + // Codex OAuth 凭证 - 从后端别名配置加载,这里返回空数组触发降级逻辑 if (type === "codex") { - return ["codex-mini-latest"]; + return []; } // Qwen OAuth 凭证 @@ -145,62 +144,10 @@ const extractSupportedModels = ( return []; }; -/** Provider type 到 registry ID 的映射 */ -const getRegistryIdFromType = (providerType: string): string => { - const typeMap: Record = { - openai: "openai", - anthropic: "anthropic", - gemini: "google", - kiro: "kiro", - claude: "anthropic", - claude_oauth: "anthropic", - qwen: "alibaba", - codex: "openai", - antigravity: "antigravity", - iflow: "iflowcn", - gemini_api_key: "google", - }; - return typeMap[providerType.toLowerCase()] || providerType.toLowerCase(); -}; - -/** 需要使用别名配置的 Provider */ -const ALIAS_PROVIDERS = ["antigravity", "kiro"]; - -/** Provider 显示名称 */ -const getProviderLabel = (providerType: string): string => { - const labelMap: Record = { - kiro: "Kiro", - gemini: "Gemini OAuth", - qwen: "通义千问", - antigravity: "Antigravity", - codex: "Codex", - claude_oauth: "Claude OAuth", - claude: "Claude", - openai: "OpenAI", - anthropic: "Anthropic", - gemini_api_key: "Gemini API Key", - iflow: "iFlow", - }; - return ( - labelMap[providerType.toLowerCase()] || - providerType.charAt(0).toUpperCase() + providerType.slice(1) - ); -}; - // ============================================================================ // 类型 // ============================================================================ -interface ConfiguredProvider { - key: string; - label: string; - registryId: string; - fallbackRegistryId?: string; - type: string; - credentialType: string; // 添加凭证类型字段 - providerId?: string; // 添加 providerId 字段用于特殊判断(如 DeepSeek) -} - interface TerminalAIModeSelectorProps { /** 当前 Provider ID */ providerId: string; @@ -226,142 +173,51 @@ export const TerminalAIModeSelector: React.FC = ({ className, }) => { const [open, setOpen] = useState(false); - const [aliasConfig, setAliasConfig] = useState( - null, - ); - // 获取凭证数据 - const { overview: oauthCredentials } = useProviderPool(); - const { providers: apiKeyProviders } = useApiKeyProvider(); - const { models: registryModels } = useModelRegistry({ autoLoad: true }); - - // 计算已配置的 Provider 列表 - const configuredProviders = useMemo(() => { - const providerMap = new Map(); - - // OAuth 凭证 - oauthCredentials.forEach((overview) => { - if (overview.credentials.length > 0) { - const key = overview.provider_type; - // 获取第一个凭证的类型作为代表 - const firstCredential = overview.credentials[0]; - const credentialType = firstCredential.credential_type || key; - - if (!providerMap.has(key)) { - providerMap.set(key, { - key, - label: getProviderLabel(key), - registryId: getRegistryIdFromType(key), - type: key, - credentialType, - }); - } - } - }); - - // API Key Provider - apiKeyProviders - .filter((p) => p.api_key_count > 0 && p.enabled) - .forEach((provider) => { - let key = provider.id; - let label = provider.name; - - if (providerMap.has(key)) { - key = `${provider.id}_api_key`; - label = `${provider.name} API Key`; - } - - if (!providerMap.has(key)) { - // 使用 provider.type 映射到 registryId,而不是 provider.id - const registryId = getRegistryIdFromType(provider.type); - providerMap.set(key, { - key, - label, - registryId, - fallbackRegistryId: registryId, - type: provider.type, - credentialType: `${provider.type}_key`, // API Key 类型 - providerId: provider.id, // 保存原始 provider.id - }); - } - }); - - return Array.from(providerMap.values()); - }, [oauthCredentials, apiKeyProviders]); + // 获取已配置的 Provider 列表(使用共享 hook) + const { providers: configuredProviders } = useConfiguredProviders(); // 当前选中的 Provider const selectedProvider = useMemo(() => { return configuredProviders.find((p) => p.key === providerId); }, [configuredProviders, providerId]); - // 加载别名配置 - useEffect(() => { - if (selectedProvider && ALIAS_PROVIDERS.includes(selectedProvider.key)) { - getProviderAliasConfig(selectedProvider.key) - .then(setAliasConfig) - .catch(() => setAliasConfig(null)); - } else { - setAliasConfig(null); - } - }, [selectedProvider]); + // 获取模型列表(使用共享 hook) + const { modelIds: hookModels, loading: modelsLoading } = + useProviderModels(selectedProvider); // 当前 Provider 的模型列表 + // Terminal 有特殊的降级策略:优先使用 extractSupportedModels const currentModels = useMemo(() => { if (!selectedProvider) return []; - // 别名 Provider 使用别名配置 - if (ALIAS_PROVIDERS.includes(selectedProvider.key) && aliasConfig) { - return aliasConfig.models; + // 别名 Provider 使用共享 hook 的结果 + if (isAliasProvider(selectedProvider.key)) { + return hookModels; } // 优先使用凭证池中的模型列表(从后端 extract_supported_models 逻辑) const credentialModels = extractSupportedModels( selectedProvider.type, - selectedProvider.credentialType, - selectedProvider.providerId, // 传递 providerId + selectedProvider.credentialType || "", + selectedProvider.providerId, ); if (credentialModels.length > 0) { return credentialModels; } - // 降级:从 model_registry 获取 - let models = registryModels.filter( - (m) => m.provider_id === selectedProvider.registryId, - ); - - if (models.length === 0 && selectedProvider.fallbackRegistryId) { - models = registryModels.filter( - (m) => m.provider_id === selectedProvider.fallbackRegistryId, - ); - } - - // 排序:使用 release_date 和 is_latest 字段 - const sortedModels = [...models].sort((a, b) => { - // 1. is_latest 优先 - if (a.is_latest && !b.is_latest) return -1; - if (!a.is_latest && b.is_latest) return 1; - - // 2. 按 release_date 降序(最新的在前) - if (a.release_date && b.release_date) { - return b.release_date.localeCompare(a.release_date); - } - if (a.release_date && !b.release_date) return -1; - if (!a.release_date && b.release_date) return 1; - - // 3. 按 display_name 字母序 - return a.display_name.localeCompare(b.display_name); - }); - - return sortedModels.map((m) => m.id); - }, [selectedProvider, registryModels, aliasConfig]); + // 降级:使用共享 hook 的结果 + return hookModels; + }, [selectedProvider, hookModels]); // 自动选择第一个模型 useEffect(() => { + // 等待模型加载完成 if ( selectedProvider && - ALIAS_PROVIDERS.includes(selectedProvider.key) && - !aliasConfig + isAliasProvider(selectedProvider.key) && + modelsLoading ) { return; } @@ -369,7 +225,7 @@ export const TerminalAIModeSelector: React.FC = ({ if (currentModels.length > 0 && !currentModels.includes(modelId)) { onModelChange(currentModels[0]); } - }, [currentModels, modelId, onModelChange, selectedProvider, aliasConfig]); + }, [currentModels, modelId, onModelChange, selectedProvider, modelsLoading]); // 初始化 Provider useEffect(() => { diff --git a/src/hooks/useAliasConfig.ts b/src/hooks/useAliasConfig.ts new file mode 100644 index 000000000..815b88699 --- /dev/null +++ b/src/hooks/useAliasConfig.ts @@ -0,0 +1,120 @@ +/** + * @file 别名配置加载 Hook + * @description 根据选中的 Provider 加载对应的别名配置 + * @module hooks/useAliasConfig + */ + +import { useState, useEffect } from "react"; +import { getProviderAliasConfig } from "@/lib/api/modelRegistry"; +import { + isAliasProvider, + getAliasConfigKey, +} from "@/lib/constants/providerMappings"; +import type { ProviderAliasConfig } from "@/lib/types/modelRegistry"; +import type { ConfiguredProvider } from "./useConfiguredProviders"; + +// ============================================================================ +// 类型定义 +// ============================================================================ + +export interface UseAliasConfigResult { + /** 别名配置(如果 Provider 使用别名配置) */ + aliasConfig: ProviderAliasConfig | null; + /** 是否正在加载 */ + loading: boolean; + /** 加载错误 */ + error: string | null; +} + +// ============================================================================ +// Hook 实现 +// ============================================================================ + +/** + * 加载 Provider 的别名配置 + * + * 当选中的 Provider 在 ALIAS_PROVIDERS 列表中时, + * 自动加载对应的别名配置文件。 + * + * @param selectedProvider 当前选中的 Provider + * @returns 别名配置、加载状态和错误信息 + * + * @example + * ```tsx + * const { aliasConfig, loading, error } = useAliasConfig(selectedProvider); + * + * if (loading) return ; + * if (error) return ; + * + * // 使用 aliasConfig.models 获取模型列表 + * ``` + */ +export function useAliasConfig( + selectedProvider: ConfiguredProvider | undefined | null, +): UseAliasConfigResult { + const [aliasConfig, setAliasConfig] = useState( + null, + ); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + // 如果没有选中 Provider,清空配置 + if (!selectedProvider) { + setAliasConfig(null); + setLoading(false); + setError(null); + return; + } + + // 如果不是别名 Provider,清空配置 + if (!isAliasProvider(selectedProvider.key)) { + setAliasConfig(null); + setLoading(false); + setError(null); + return; + } + + // 加载别名配置 + setLoading(true); + setError(null); + + // 使用映射获取实际的别名配置文件名 + const aliasConfigKey = getAliasConfigKey(selectedProvider.key); + + getProviderAliasConfig(aliasConfigKey) + .then((config) => { + setAliasConfig(config); + setError(null); + }) + .catch((err) => { + console.error("加载别名配置失败:", err); + setAliasConfig(null); + setError(err instanceof Error ? err.message : "加载别名配置失败"); + }) + .finally(() => { + setLoading(false); + }); + // 只依赖 key 变化,避免 selectedProvider 对象引用变化导致不必要的重新加载 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedProvider?.key]); + + return { + aliasConfig, + loading, + error, + }; +} + +/** + * 简化版本:只返回别名配置 + * 适用于不需要加载状态的场景 + */ +export function useAliasConfigSimple( + selectedProvider: ConfiguredProvider | undefined | null, +): ProviderAliasConfig | null { + const { aliasConfig } = useAliasConfig(selectedProvider); + return aliasConfig; +} + +export default useAliasConfig; diff --git a/src/hooks/useConfiguredProviders.ts b/src/hooks/useConfiguredProviders.ts new file mode 100644 index 000000000..c902dc3ac --- /dev/null +++ b/src/hooks/useConfiguredProviders.ts @@ -0,0 +1,143 @@ +/** + * @file 已配置 Provider 列表 Hook + * @description 从 OAuth 凭证和 API Key Provider 中提取已配置的 Provider 列表 + * @module hooks/useConfiguredProviders + */ + +import { useMemo } from "react"; +import { useProviderPool } from "./useProviderPool"; +import { useApiKeyProvider } from "./useApiKeyProvider"; +import { + getRegistryIdFromType, + getProviderLabel, +} from "@/lib/constants/providerMappings"; + +// ============================================================================ +// 类型定义 +// ============================================================================ + +/** + * 已配置的 Provider 信息 + */ +export interface ConfiguredProvider { + /** Provider 唯一标识 */ + key: string; + /** 显示标签 */ + label: string; + /** 模型注册表中的 provider_id */ + registryId: string; + /** 回退的 registry_id(当 registryId 没有模型时使用) */ + fallbackRegistryId?: string; + /** 原始 provider type,用于确定 API 协议 */ + type: string; + /** 凭证类型(用于特殊处理) */ + credentialType?: string; + /** Provider ID(用于 API Key Provider) */ + providerId?: string; +} + +export interface UseConfiguredProvidersResult { + /** 已配置的 Provider 列表 */ + providers: ConfiguredProvider[]; + /** 是否正在加载 */ + loading: boolean; +} + +// ============================================================================ +// Hook 实现 +// ============================================================================ + +/** + * 获取已配置的 Provider 列表 + * + * 从 OAuth 凭证池和 API Key Provider 中提取已配置凭证的 Provider, + * 合并去重后返回统一的 Provider 列表。 + * + * @returns 已配置的 Provider 列表和加载状态 + * + * @example + * ```tsx + * const { providers, loading } = useConfiguredProviders(); + * + * if (loading) return ; + * + * return ( + * + * ); + * ``` + */ +export function useConfiguredProviders(): UseConfiguredProvidersResult { + // 获取凭证池数据 + const { overview: oauthCredentials, loading: oauthLoading } = + useProviderPool(); + const { providers: apiKeyProviders, loading: apiKeyLoading } = + useApiKeyProvider(); + + // 计算已配置的 Provider 列表 + const providers = useMemo(() => { + const providerMap = new Map(); + + // 1. 从 OAuth 凭证提取 Provider + oauthCredentials.forEach((overview) => { + if (overview.credentials.length > 0) { + const key = overview.provider_type; + const firstCredential = overview.credentials[0]; + const credentialType = firstCredential.credential_type || key; + + if (!providerMap.has(key)) { + providerMap.set(key, { + key, + label: getProviderLabel(key), + registryId: getRegistryIdFromType(key), + type: key, + credentialType, + }); + } + } + }); + + // 2. 从 API Key Provider 提取 + // 使用 provider.id 作为 key,确保每个 Provider 单独显示 + // 特殊处理:如果与 OAuth 凭证冲突,使用带后缀的 key + apiKeyProviders + .filter((p) => p.api_key_count > 0 && p.enabled) + .forEach((provider) => { + let key = provider.id; + let label = provider.name; + + // 如果 key 与 OAuth 凭证冲突,添加 "_api_key" 后缀 + // 例如:Gemini OAuth 的 key 是 "gemini",Gemini API Key 的 key 变成 "gemini_api_key" + if (providerMap.has(key)) { + key = `${provider.id}_api_key`; + label = `${provider.name} API Key`; + } + + if (!providerMap.has(key)) { + // 优先使用 provider.id 作为 registryId(适用于系统预设的 Provider,如 deepseek, moonshot) + // 如果模型注册表中没有该 id 的模型,则回退到使用 type 映射(适用于自定义 Provider) + providerMap.set(key, { + key, + label, + registryId: provider.id, + fallbackRegistryId: getRegistryIdFromType(provider.type), + type: provider.type, + credentialType: `${provider.type}_key`, + providerId: provider.id, + }); + } + }); + + return Array.from(providerMap.values()); + }, [oauthCredentials, apiKeyProviders]); + + return { + providers, + loading: oauthLoading || apiKeyLoading, + }; +} + +export default useConfiguredProviders; diff --git a/src/hooks/useProviderModels.ts b/src/hooks/useProviderModels.ts new file mode 100644 index 000000000..6c514ac9f --- /dev/null +++ b/src/hooks/useProviderModels.ts @@ -0,0 +1,220 @@ +/** + * @file Provider 模型列表 Hook + * @description 根据 Provider 获取对应的模型列表 + * @module hooks/useProviderModels + */ + +import { useMemo } from "react"; +import { useModelRegistry } from "./useModelRegistry"; +import { useAliasConfig } from "./useAliasConfig"; +import { isAliasProvider } from "@/lib/constants/providerMappings"; +import type { ConfiguredProvider } from "./useConfiguredProviders"; +import type { + EnhancedModelMetadata, + ProviderAliasConfig, +} from "@/lib/types/modelRegistry"; + +// ============================================================================ +// 类型定义 +// ============================================================================ + +export interface UseProviderModelsOptions { + /** 是否返回完整的模型元数据(默认只返回模型 ID) */ + returnFullMetadata?: boolean; + /** 是否自动加载模型注册表 */ + autoLoad?: boolean; +} + +export interface UseProviderModelsResult { + /** 模型 ID 列表 */ + modelIds: string[]; + /** 完整的模型元数据列表(仅当 returnFullMetadata 为 true 时有值) */ + models: EnhancedModelMetadata[]; + /** 是否正在加载 */ + loading: boolean; + /** 加载错误 */ + error: string | null; +} + +// ============================================================================ +// 工具函数 +// ============================================================================ + +/** + * 模型排序函数 + * 排序优先级:is_latest > release_date(降序) > display_name(字母序) + */ +function sortModels(models: EnhancedModelMetadata[]): EnhancedModelMetadata[] { + return [...models].sort((a, b) => { + // 1. is_latest 优先 + if (a.is_latest && !b.is_latest) return -1; + if (!a.is_latest && b.is_latest) return 1; + + // 2. 按 release_date 降序(最新的在前) + if (a.release_date && b.release_date) { + return b.release_date.localeCompare(a.release_date); + } + if (a.release_date && !b.release_date) return -1; + if (!a.release_date && b.release_date) return 1; + + // 3. 按 display_name 字母序 + return a.display_name.localeCompare(b.display_name); + }); +} + +/** + * 将别名配置中的模型转换为 EnhancedModelMetadata 格式 + */ +function convertAliasModelsToMetadata( + models: string[], + aliasConfig: ProviderAliasConfig, + providerId: string, + providerName: string, +): EnhancedModelMetadata[] { + return models.map((modelName): EnhancedModelMetadata => { + const aliasInfo = aliasConfig.aliases[modelName]; + return { + id: modelName, + display_name: modelName, + provider_id: providerId, + provider_name: providerName, + family: aliasInfo?.provider || null, + tier: "pro" as const, + capabilities: { + vision: false, + tools: true, + streaming: true, + json_mode: true, + function_calling: true, + reasoning: modelName.includes("thinking"), + }, + pricing: null, + limits: { + context_length: null, + max_output_tokens: null, + requests_per_minute: null, + tokens_per_minute: null, + }, + status: "active" as const, + release_date: null, + is_latest: false, + description: + aliasInfo?.description || `${aliasInfo?.actual || modelName}`, + source: "custom" as const, + created_at: Date.now() / 1000, + updated_at: Date.now() / 1000, + }; + }); +} + +// ============================================================================ +// Hook 实现 +// ============================================================================ + +/** + * 获取 Provider 的模型列表 + * + * 根据 Provider 类型,从别名配置或模型注册表获取模型列表。 + * 支持返回模型 ID 列表或完整的模型元数据。 + * + * @param selectedProvider 当前选中的 Provider + * @param options 配置选项 + * @returns 模型列表、加载状态和错误信息 + * + * @example + * ```tsx + * // 只获取模型 ID + * const { modelIds, loading } = useProviderModels(selectedProvider); + * + * // 获取完整元数据 + * const { models, loading } = useProviderModels(selectedProvider, { + * returnFullMetadata: true + * }); + * ``` + */ +export function useProviderModels( + selectedProvider: ConfiguredProvider | undefined | null, + options: UseProviderModelsOptions = {}, +): UseProviderModelsResult { + const { returnFullMetadata = false, autoLoad = true } = options; + + // 获取模型注册表数据 + const { + models: registryModels, + loading: registryLoading, + error: registryError, + } = useModelRegistry({ autoLoad }); + + // 获取别名配置 + const { aliasConfig, loading: aliasLoading } = + useAliasConfig(selectedProvider); + + // 计算模型列表 + const result = useMemo(() => { + if (!selectedProvider) { + return { modelIds: [], models: [] }; + } + + // 对于别名 Provider,使用别名配置中的模型列表 + if (isAliasProvider(selectedProvider.key) && aliasConfig) { + const modelIds = aliasConfig.models; + + if (returnFullMetadata) { + const models = convertAliasModelsToMetadata( + modelIds, + aliasConfig, + selectedProvider.key, + selectedProvider.label, + ); + return { modelIds, models }; + } + + return { modelIds, models: [] }; + } + + // 从模型注册表获取模型 + let models = registryModels.filter( + (m) => m.provider_id === selectedProvider.registryId, + ); + + // 如果没有找到模型,尝试使用 fallbackRegistryId + if (models.length === 0 && selectedProvider.fallbackRegistryId) { + models = registryModels.filter( + (m) => m.provider_id === selectedProvider.fallbackRegistryId, + ); + } + + // 排序 + const sortedModels = sortModels(models); + const modelIds = sortedModels.map((m) => m.id); + + return { + modelIds, + models: returnFullMetadata ? sortedModels : [], + }; + }, [selectedProvider, registryModels, aliasConfig, returnFullMetadata]); + + // 计算加载状态 + const loading = registryLoading || aliasLoading; + + // 计算错误状态 + const error = registryError || null; + + return { + ...result, + loading, + error, + }; +} + +/** + * 简化版本:只返回模型 ID 列表 + */ +export function useProviderModelIds( + selectedProvider: ConfiguredProvider | undefined | null, +): string[] { + const { modelIds } = useProviderModels(selectedProvider); + return modelIds; +} + +export default useProviderModels; diff --git a/src/i18n/dynamic-translation.ts b/src/i18n/dynamic-translation.ts index 0bb9a08ee..1a5fa1b69 100644 --- a/src/i18n/dynamic-translation.ts +++ b/src/i18n/dynamic-translation.ts @@ -25,239 +25,239 @@ function getCurrentLanguage(): Language { */ const TEMPLATES: Record = { // Flow Monitor - "new_flow_id": { + new_flow_id: { zh: "新 Flow ID: {id}", en: "New Flow ID: {id}", }, - "total_duration": { + total_duration: { zh: "总耗时: {duration}", en: "Total duration: {duration}", }, - "duration": { + duration: { zh: "耗时: {duration}", en: "Duration: {duration}", }, - "model_name": { + model_name: { zh: "模型: {model}", en: "Model: {model}", }, - "replay_status": { + replay_status: { zh: "重放会创建新的 Flow 并标记为 {status}", en: "Replay will create new Flow and mark as {status}", }, - "flow_count": { + flow_count: { zh: "将重放 {count} 个 Flow", en: "Will replay {count} Flow(s)", }, // Provider/Clients - "config_mismatch": { - zh: "实际生效的配置与当前选中的 \"{provider}\" 不一致", - en: "Actual effective configuration differs from currently selected \"{provider}\"", + config_mismatch: { + zh: '实际生效的配置与当前选中的 "{provider}" 不一致', + en: 'Actual effective configuration differs from currently selected "{provider}"', }, - "actual_value": { + actual_value: { zh: "实际: {value}", en: "Actual: {value}", }, - "current_value": { + current_value: { zh: "当前: {value}", en: "Current: {value}", }, // General patterns - "items_count": { + items_count: { zh: "共 {count} 个{item}", en: "Total {count} {item}(s)", }, - "loading_with_item": { + loading_with_item: { zh: "正在加载{item}...", en: "Loading {item}...", }, - "error_with_message": { + error_with_message: { zh: "{action}失败: {error}", en: "{action} failed: {error}", }, - "success_with_message": { + success_with_message: { zh: "{action}成功: {message}", en: "{action} successful: {message}", }, // Flow Monitor specific - "flow_detail_title": { + flow_detail_title: { zh: "Flow #{id}", en: "Flow #{id}", }, - "request_duration": { + request_duration: { zh: "请求耗时: {duration}", en: "Request duration: {duration}", }, - "response_duration": { + response_duration: { zh: "响应耗时: {duration}", en: "Response duration: {duration}", }, // Provider Pool - "provider_model_count": { + provider_model_count: { zh: "{provider} 有 {count} 个模型", en: "{provider} has {count} model(s)", }, - "credential_type": { + credential_type: { zh: "{type} 凭证", en: "{type} credential", }, // Settings - "setting_updated": { + setting_updated: { zh: "{setting} 已更新", en: "{setting} updated", }, - "setting_failed": { + setting_failed: { zh: "更新 {setting} 失败: {error}", en: "Failed to update {setting}: {error}", }, // Additional templates from scan results - "confirm_delete_config": { - zh: "确定要删除配置 \"{name}\" 吗?此操作无法撤销。", - en: "Are you sure you want to delete configuration \"{name}\"? This action cannot be undone.", + confirm_delete_config: { + zh: '确定要删除配置 "{name}" 吗?此操作无法撤销。', + en: 'Are you sure you want to delete configuration "{name}"? This action cannot be undone.', }, - "start_creating": { + start_creating: { zh: "开始{theme}创作...", en: "Start creating {theme}...", }, - "fetch_models_failed": { + fetch_models_failed: { zh: "获取 {provider} 模型列表失败: {error}", en: "Failed to fetch {provider} model list: {error}", }, - "credential_label": { + credential_label: { zh: "{type} 凭证: {provider} - {id}", en: "{type} credential: {provider} - {id}", }, - "filter_state": { + filter_state: { zh: "状态: {state}", en: "Status: {state}", }, - "filter_operator": { + filter_operator: { zh: "比较运算符: {op}", en: "Comparison operators: {op}", }, - "selected_flow_count": { + selected_flow_count: { zh: "已选择 {count} 个 Flow", en: "Selected {count} Flow(s)", }, - "display_model_count": { + display_model_count: { zh: "显示 {displayed} / {total} 个模型", en: "Showing {displayed} of {total} model(s)", }, - "usage_count": { + usage_count: { zh: "使用 {count} 次", en: "Used {count} time(s)", }, - "retain_hours": { + retain_hours: { zh: "保留最近 {hours} 小时的数据", en: "Retain data from the last {hours} hour(s)", }, - "retain_days": { + retain_days: { zh: "保留最近 {days} 天的数据", en: "Retain data from the last {days} day(s)", }, - "retain_records": { + retain_records: { zh: "只保留最近 {count} 条记录", en: "Only keep the most recent {count} record(s)", }, - "storage_limit": { + storage_limit: { zh: "存储大小限制:{size} GB", en: "Storage size limit: {size} GB", }, - "start_failed": { + start_failed: { zh: "启动失败: {error}", en: "Start failed: {error}", }, - "stop_failed": { + stop_failed: { zh: "停止失败: {error}", en: "Stop failed: {error}", }, - "switched_to": { + switched_to: { zh: "已切换到 {provider}", en: "Switched to {provider}", }, - "switch_failed": { + switch_failed: { zh: "切换失败: {error}", en: "Switch failed: {error}", }, - "request_failed": { + request_failed: { zh: "请求失败: {error}", en: "Request failed: {error}", }, - "message_list_title": { + message_list_title: { zh: "消息列表 ({count})", en: "Message list ({count})", }, - "tool_definitions_title": { + tool_definitions_title: { zh: "工具定义 ({count})", en: "Tool definitions ({count})", }, - "tool_calls_title": { + tool_calls_title: { zh: "工具调用 ({count})", en: "Tool calls ({count})", }, - "total_records": { + total_records: { zh: "共 {total} 条记录", en: "Total {total} record(s)", }, - "credential_pool_label": { + credential_pool_label: { zh: "凭证池: {provider}", en: "Credential pool: {provider}", }, - "installed_count": { + installed_count: { zh: "已安装 ({count})", en: "Installed ({count})", }, - "env_vars_title": { + env_vars_title: { zh: ".env 环境变量 ({provider})", en: ".env environment variables ({provider})", }, - "collapse_themes": { + collapse_themes: { zh: "收起", en: "Collapse", }, - "more_themes": { + more_themes: { zh: "更多主题", en: "More themes", }, - "batch_operation_failed": { + batch_operation_failed: { zh: "批量{operation}失败", en: "Batch {operation} failed", }, - "selected_total": { + selected_total: { zh: "已选择 {selected} / {total}", en: "Selected {selected} of {total}", }, - "confirm_delete_flows": { + confirm_delete_flows: { zh: "确定要删除选中的 {count} 个 Flow 吗?", en: "Are you sure you want to delete the selected {count} Flow(s)?", }, - "theme_creation_helper": { + theme_creation_helper: { zh: "你是一位专业的内容创作教练,当前帮助用户进行「{theme}」创作。", - en: "You are a professional content creation coach, currently helping users create \"{theme}\".", + en: 'You are a professional content creation coach, currently helping users create "{theme}".', }, - "model_not_supported": { + model_not_supported: { zh: "模型 {model} 不在 {provider} 支持列表中,自动切换到 {fallback}", en: "Model {model} is not in {provider} support list, automatically switching to {fallback}", }, - "send_failed": { + send_failed: { zh: "发送失败: {error}", en: "Send failed: {error}", }, - "response_error": { + response_error: { zh: "响应错误: {error}", en: "Response error: {error}", }, - "file_write_triggered": { + file_write_triggered: { zh: "触发文件写入: {path}", en: "Triggered file write: {path}", }, - "tool_call_exists": { + tool_call_exists: { zh: "工具调用已存在,跳过: {id}", en: "Tool call already exists, skipping: {id}", }, @@ -269,7 +269,7 @@ const TEMPLATES: Record = { */ function replacePlaceholders( template: string, - values: Record + values: Record, ): string { return template.replace(/\{(\w+)\}/g, (match, key) => { return values[key]?.toString() ?? match; @@ -294,7 +294,7 @@ function replacePlaceholders( export function t( key: string, values?: Record, - language?: Language + language?: Language, ): string { const lang = language || getCurrentLanguage(); const template = TEMPLATES[key]; @@ -363,7 +363,7 @@ export function tTotalDuration(ms: number, language?: Language): string { } // React Hook for dynamic translations -import { useContext, useEffect, useState } from "react"; +import { useEffect, useState } from "react"; import { useI18nPatch } from "./I18nPatchProvider"; /** @@ -378,7 +378,7 @@ import { useI18nPatch } from "./I18nPatchProvider"; */ export function useT( key: string, - values?: Record + values?: Record, ): string { const { language } = useI18nPatch(); const [translated, setTranslated] = useState(() => t(key, values, language)); @@ -399,7 +399,7 @@ export function useT( */ export function useTranslator(): ( key: string, - values?: Record + values?: Record, ) => string { const { language } = useI18nPatch(); return (key: string, values?: Record) => diff --git a/src/i18n/index.ts b/src/i18n/index.ts index c5a302285..9ffa7ea25 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -24,11 +24,7 @@ export { export { replaceTextInDOM } from "./dom-replacer"; // Text map and language types -export { - getTextMap, - type Language, - isValidLanguage, -} from "./text-map"; +export { getTextMap, type Language, isValidLanguage } from "./text-map"; // withI18nPatch HOC (for class components) export { withI18nPatch } from "./withI18nPatch"; diff --git a/src/lib/constants/providerMappings.ts b/src/lib/constants/providerMappings.ts new file mode 100644 index 000000000..2a5346f68 --- /dev/null +++ b/src/lib/constants/providerMappings.ts @@ -0,0 +1,129 @@ +/** + * @file Provider 映射常量和工具函数 + * @description 统一管理 Provider 类型映射、别名配置等共享常量 + * @module lib/constants/providerMappings + */ + +// ============================================================================ +// 别名配置相关常量 +// ============================================================================ + +/** + * 需要使用别名配置的 Provider 列表 + * 这些 Provider 的模型列表从别名配置文件获取,而不是从模型注册表 + */ +export const ALIAS_PROVIDERS = [ + "antigravity", + "kiro", + "codex", + "gemini", + "gemini_api_key", +] as const; + +export type AliasProvider = (typeof ALIAS_PROVIDERS)[number]; + +/** + * 别名配置文件名映射 + * 某些 Provider 共享同一个别名配置文件 + */ +export const ALIAS_CONFIG_MAPPING: Record = { + gemini_api_key: "gemini", // Gemini API Key 使用 gemini 的别名配置 +}; + +// ============================================================================ +// Provider 类型映射 +// ============================================================================ + +/** + * Provider 类型到模型注册表 provider_id 的映射 + * 用于从模型注册表获取对应 Provider 的模型列表 + */ +export const PROVIDER_TYPE_TO_REGISTRY_ID: Record = { + // 主流 AI + openai: "openai", + anthropic: "anthropic", + "anthropic-compatible": "anthropic", + gemini: "gemini", + // 云服务 + "azure-openai": "openai", + vertexai: "google", + // 本地/自托管 + ollama: "ollama", + // 特殊 Provider + kiro: "kiro", + claude: "anthropic", + claude_oauth: "anthropic", + qwen: "alibaba", + codex: "codex", + antigravity: "antigravity", + iflow: "openai", + gemini_api_key: "gemini", +}; + +/** + * Provider 显示名称映射 + */ +export const PROVIDER_DISPLAY_NAMES: Record = { + kiro: "Kiro", + gemini: "Gemini OAuth", + qwen: "通义千问", + antigravity: "Antigravity", + codex: "Codex", + claude_oauth: "Claude OAuth", + claude: "Claude", + openai: "OpenAI", + anthropic: "Anthropic", + "anthropic-compatible": "Anthropic Compatible", + "azure-openai": "Azure OpenAI", + vertexai: "VertexAI", + ollama: "Ollama", + gemini_api_key: "Gemini API Key", + iflow: "iFlow", +}; + +// ============================================================================ +// 工具函数 +// ============================================================================ + +/** + * 获取 Provider 类型对应的模型注册表 ID + * @param providerType Provider 类型 + * @returns 模型注册表中的 provider_id + */ +export function getRegistryIdFromType(providerType: string): string { + return ( + PROVIDER_TYPE_TO_REGISTRY_ID[providerType.toLowerCase()] || + providerType.toLowerCase() + ); +} + +/** + * 获取 Provider 的显示标签 + * @param providerType Provider 类型 + * @returns 用于 UI 显示的标签 + */ +export function getProviderLabel(providerType: string): string { + return ( + PROVIDER_DISPLAY_NAMES[providerType.toLowerCase()] || + providerType.charAt(0).toUpperCase() + providerType.slice(1) + ); +} + +/** + * 获取别名配置文件的 key + * 某些 Provider 共享同一个别名配置文件 + * @param providerKey Provider key + * @returns 别名配置文件的 key + */ +export function getAliasConfigKey(providerKey: string): string { + return ALIAS_CONFIG_MAPPING[providerKey] || providerKey; +} + +/** + * 检查 Provider 是否使用别名配置 + * @param providerKey Provider key + * @returns 是否使用别名配置 + */ +export function isAliasProvider(providerKey: string): boolean { + return ALIAS_PROVIDERS.includes(providerKey as AliasProvider); +}