diff --git a/src-tauri/assets/builtin/mimo-models.json b/src-tauri/assets/builtin/mimo-models.json new file mode 100644 index 0000000..24d8e00 --- /dev/null +++ b/src-tauri/assets/builtin/mimo-models.json @@ -0,0 +1,58 @@ +{ + "models": [ + { + "slug": "mimo-v2.5-pro", + "display_name": "mimo-v2.5-pro", + "description": "MiMo-v2.5-Pro: Trillion-parameter Flagship Agent Foundation", + "default_reasoning_level": "high", + "supported_reasoning_levels": [ + { "effort": "none", "description": "Disable Thinking" }, + { "effort": "high", "description": "Enabled Thinking" } + ], + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": true, + "priority": 0, + "base_instructions": "You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024.", + "supports_reasoning_summaries": true, + "default_reasoning_summary": "none", + "support_verbosity": false, + "truncation_policy": { "mode": "bytes", "limit": 10000 }, + "supports_parallel_tool_calls": false, + "supports_image_detail_original": false, + "context_window": 1048576, + "max_context_window": 1048576, + "effective_context_window_percent": 95, + "experimental_supported_tools": [], + "input_modalities": ["text"], + "supports_search_tool": false + }, + { + "slug": "mimo-v2.5", + "display_name": "mimo-v2.5", + "description": "MiMo-V2.5: Native Omni-modal Perception Model", + "default_reasoning_level": "high", + "supported_reasoning_levels": [ + { "effort": "none", "description": "Disable Thinking" }, + { "effort": "high", "description": "Enabled Thinking" } + ], + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": true, + "priority": 1, + "base_instructions": "You are MiMo, an AI assistant developed by Xiaomi. Today's date: {date} {week}. Your knowledge cutoff date is December 2024.", + "supports_reasoning_summaries": true, + "default_reasoning_summary": "none", + "support_verbosity": false, + "truncation_policy": { "mode": "bytes", "limit": 10000 }, + "supports_parallel_tool_calls": false, + "supports_image_detail_original": true, + "context_window": 1048576, + "max_context_window": 1048576, + "effective_context_window_percent": 95, + "experimental_supported_tools": [], + "input_modalities": ["text", "image"], + "supports_search_tool": false + } + ] +} diff --git a/src-tauri/assets/builtin/mimo.toml b/src-tauri/assets/builtin/mimo.toml new file mode 100644 index 0000000..c27941e --- /dev/null +++ b/src-tauri/assets/builtin/mimo.toml @@ -0,0 +1,14 @@ +model = "mimo-v2.5-pro" +model_provider = "mimo" +model_reasoning_effort = "high" +model_supports_reasoning_summaries = true +model_reasoning_summary = "none" +model_context_window = 1048576 +web_search = "disabled" +model_catalog_json = "~/.codex/models.json" + +[model_providers.mimo] +name = "mimo" +base_url = "https://api.xiaomimimo.com/v1" +wire_api = "responses" +experimental_bearer_token = "<你的 MiMo API Key>" \ No newline at end of file diff --git a/src-tauri/assets/builtin/opencode-models.json b/src-tauri/assets/builtin/opencode-models.json new file mode 100644 index 0000000..249f9a2 --- /dev/null +++ b/src-tauri/assets/builtin/opencode-models.json @@ -0,0 +1,197 @@ +{ + "models": [ + { + "slug": "glm-5.2", + "display_name": "GLM 5.2", + "description": "GLM 5.2 via OpenCode Go", + "default_reasoning_level": "high", + "supported_reasoning_levels": [ + { + "effort": "high", + "description": "Enhanced reasoning" + }, + { + "effort": "max", + "description": "Deep reasoning" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": true, + "priority": 0, + "base_instructions": "", + "supports_reasoning_summaries": true, + "context_window": 204800, + "max_context_window": 204800, + "default_reasoning_summary": "none", + "support_verbosity": false, + "apply_patch_tool_type": "freeform", + "truncation_policy": { + "mode": "bytes", + "limit": 10000 + }, + "effective_context_window_percent": 95, + "supports_parallel_tool_calls": true, + "experimental_supported_tools": [], + "input_modalities": [ + "text" + ] + }, + { + "slug": "glm-5.1", + "display_name": "GLM 5.1", + "description": "GLM 5.1 via OpenCode Go", + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": true, + "priority": 1, + "base_instructions": "", + "supports_reasoning_summaries": true, + "context_window": 204800, + "max_context_window": 204800, + "default_reasoning_summary": "none", + "support_verbosity": false, + "apply_patch_tool_type": "freeform", + "truncation_policy": { + "mode": "bytes", + "limit": 10000 + }, + "effective_context_window_percent": 95, + "supports_parallel_tool_calls": true, + "experimental_supported_tools": [], + "input_modalities": [ + "text" + ] + }, + { + "slug": "kimi-k2.7-code", + "display_name": "Kimi K2.7 Code", + "description": "Kimi K2.7 Code via OpenCode Go", + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": true, + "priority": 2, + "base_instructions": "", + "supports_reasoning_summaries": true, + "context_window": 262144, + "max_context_window": 262144, + "default_reasoning_summary": "none", + "support_verbosity": false, + "apply_patch_tool_type": "freeform", + "truncation_policy": { + "mode": "bytes", + "limit": 10000 + }, + "effective_context_window_percent": 95, + "supports_parallel_tool_calls": true, + "experimental_supported_tools": [], + "input_modalities": [ + "text" + ] + }, + { + "slug": "deepseek-v4-pro", + "display_name": "DeepSeek V4 Pro", + "description": "DeepSeek V4 Pro via OpenCode Go", + "default_reasoning_level": "high", + "supported_reasoning_levels": [ + { + "effort": "high", + "description": "Enhanced reasoning" + }, + { + "effort": "max", + "description": "Deep reasoning" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": true, + "priority": 3, + "base_instructions": "", + "supports_reasoning_summaries": true, + "context_window": 1048576, + "max_context_window": 1048576, + "default_reasoning_summary": "none", + "support_verbosity": false, + "apply_patch_tool_type": "freeform", + "truncation_policy": { + "mode": "bytes", + "limit": 10000 + }, + "effective_context_window_percent": 95, + "supports_parallel_tool_calls": true, + "experimental_supported_tools": [], + "input_modalities": [ + "text" + ] + }, + { + "slug": "deepseek-v4-flash", + "display_name": "DeepSeek V4 Flash", + "description": "DeepSeek V4 Flash via OpenCode Go", + "default_reasoning_level": "low", + "supported_reasoning_levels": [ + { + "effort": "low", + "description": "Fast responses with lighter reasoning" + }, + { + "effort": "high", + "description": "Enhanced reasoning" + }, + { + "effort": "max", + "description": "Deep reasoning" + } + ], + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": true, + "priority": 4, + "base_instructions": "", + "supports_reasoning_summaries": true, + "context_window": 1048576, + "max_context_window": 1048576, + "default_reasoning_summary": "none", + "support_verbosity": false, + "apply_patch_tool_type": "freeform", + "truncation_policy": { + "mode": "bytes", + "limit": 10000 + }, + "effective_context_window_percent": 95, + "supports_parallel_tool_calls": true, + "experimental_supported_tools": [], + "input_modalities": [ + "text" + ] + }, + { + "slug": "mimo-v2.5-pro", + "display_name": "MiMo V2.5 Pro", + "description": "MiMo V2.5 Pro via OpenCode Go", + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": true, + "priority": 5, + "base_instructions": "", + "supports_reasoning_summaries": true, + "context_window": 1048576, + "max_context_window": 1048576, + "default_reasoning_summary": "none", + "support_verbosity": false, + "apply_patch_tool_type": "freeform", + "truncation_policy": { + "mode": "bytes", + "limit": 10000 + }, + "effective_context_window_percent": 95, + "supports_parallel_tool_calls": true, + "experimental_supported_tools": [], + "input_modalities": [ + "text" + ] + } + ] +} \ No newline at end of file diff --git a/src-tauri/assets/builtin/opencode.toml b/src-tauri/assets/builtin/opencode.toml index 9c27a26..3bb5015 100644 --- a/src-tauri/assets/builtin/opencode.toml +++ b/src-tauri/assets/builtin/opencode.toml @@ -1,8 +1,7 @@ -model = "deepseek-v4-flash" +model = "glm-5.2" model_provider = "opencode-go" -preferred_auth_method = "apikey" -forced_login_method = "api" model_reasoning_effort = "high" +disable_response_storage = true model_catalog_json = "~/.codex/models.json" [model_providers.opencode-go] diff --git a/src-tauri/assets/builtin/openrouter.toml b/src-tauri/assets/builtin/openrouter.toml new file mode 100644 index 0000000..e76a4b4 --- /dev/null +++ b/src-tauri/assets/builtin/openrouter.toml @@ -0,0 +1,10 @@ +model = "openai/gpt-5.6-sol" +model_provider = "openrouter" +model_reasoning_effort = "high" +disable_response_storage = true + +[model_providers.openrouter] +name = "OpenRouter" +base_url = "https://openrouter.ai/api/v1" +wire_api = "responses" +experimental_bearer_token = "<你的 OpenRouter API Key>" \ No newline at end of file diff --git a/src-tauri/src/builtin.rs b/src-tauri/src/builtin.rs index 0a34989..e6a58f8 100644 --- a/src-tauri/src/builtin.rs +++ b/src-tauri/src/builtin.rs @@ -5,6 +5,8 @@ pub const KIND_MINIMAX: &str = "minimax"; pub const KIND_ZHIPU: &str = "zhipu"; pub const KIND_CHATGPT: &str = "chatgpt"; pub const KIND_OPENCODE: &str = "opencode"; +pub const KIND_OPENROUTER: &str = "openrouter"; +pub const KIND_MIMO: &str = "mimo"; pub const DEEPSEEK_CONFIG: &[u8] = include_bytes!("../assets/builtin/deepseek.toml"); pub const DEEPSEEK_MODELS: &[u8] = include_bytes!("../assets/builtin/deepseek-models.json"); @@ -14,6 +16,10 @@ pub const ZHIPU_CONFIG: &[u8] = include_bytes!("../assets/builtin/zhipu.toml"); pub const ZHIPU_MODELS: &[u8] = include_bytes!("../assets/builtin/zhipu-models.json"); pub const CHATGPT_CONFIG: &[u8] = include_bytes!("../assets/builtin/chatgpt.toml"); pub const OPENCODE_CONFIG: &[u8] = include_bytes!("../assets/builtin/opencode.toml"); +pub const OPENCODE_MODELS: &[u8] = include_bytes!("../assets/builtin/opencode-models.json"); +pub const OPENROUTER_CONFIG: &[u8] = include_bytes!("../assets/builtin/openrouter.toml"); +pub const MIMO_CONFIG: &[u8] = include_bytes!("../assets/builtin/mimo.toml"); +pub const MIMO_MODELS: &[u8] = include_bytes!("../assets/builtin/mimo-models.json"); pub struct BuiltinTemplate { pub kind: &'static str, @@ -29,7 +35,7 @@ pub struct BuiltinTemplate { pub insert_catalog_line: bool, } -pub const BUILTINS: [BuiltinTemplate; 5] = [ +pub const BUILTINS: [BuiltinTemplate; 7] = [ BuiltinTemplate { kind: KIND_DEEPSEEK, name: "DeepSeek", @@ -66,14 +72,43 @@ pub const BUILTINS: [BuiltinTemplate; 5] = [ catalog: None, insert_catalog_line: false, }, - // OpenAI Code 没有官方 models.json 模板,但保留空文件入口供用户自行填写模型目录 + // OpenCode Go(Zen 网关 Go 订阅)无官方 Codex 目录;模型元数据(上下文窗口、 + // 逐模型推理档位)镜像 cc-switch 的 OpenCode Go 预设(其数据源为 models.dev), + // 按 Codex 目录格式(slug + 必填 base_instructions/supports_reasoning_summaries, + // 对照 zhipu-models.json)构造。 BuiltinTemplate { kind: KIND_OPENCODE, name: "OpenCode", icon: "opencode", config: OPENCODE_CONFIG, placeholder: Some("<你的 OpenCode API Key>".as_bytes()), - catalog: Some(("models.json", b"")), + catalog: Some(("models.json", OPENCODE_MODELS)), + insert_catalog_line: false, + }, + // OpenRouter 官方支持 OpenAI 兼容 Responses API(有官方 Codex CLI 接入教程)。 + // 其 Responses 为纯无状态,store:true 会被 400 拒绝,因此必须 + // disable_response_storage。模型 slug 需带厂商前缀;聚合站模型众多, + // 不带静态目录,由编辑页"获取模型列表"(/api/v1/models)拉取 + BuiltinTemplate { + kind: KIND_OPENROUTER, + name: "OpenRouter", + icon: "openrouter", + config: OPENROUTER_CONFIG, + placeholder: Some("<你的 OpenRouter API Key>".as_bytes()), + catalog: None, + insert_catalog_line: false, + }, + // 小米 MiMo:官方支持 Responses API 并提供 Codex 配置文档,config 与 + // model-catalogs.json 均取自官方示例(web_search 按官方要求禁用;目录 + // 不带 apply_patch_tool_type——该网关拒绝 freeform 自定义工具)。 + // 默认走按量付费端点;Token Plan 用户需自行改 base_url(token-plan-cn.xiaomimimo.com/v1) + BuiltinTemplate { + kind: KIND_MIMO, + name: "小米 MiMo", + icon: "xiaomi-mimo", + config: MIMO_CONFIG, + placeholder: Some("<你的 MiMo API Key>".as_bytes()), + catalog: Some(("models.json", MIMO_MODELS)), insert_catalog_line: false, }, ]; @@ -152,7 +187,15 @@ mod tests { ); assert_eq!( OPENCODE_CONFIG, - b"model = \"deepseek-v4-flash\"\nmodel_provider = \"opencode-go\"\npreferred_auth_method = \"apikey\"\nforced_login_method = \"api\"\nmodel_reasoning_effort = \"high\"\nmodel_catalog_json = \"~/.codex/models.json\"\n\n[model_providers.opencode-go]\nname = \"OpenCode Go\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"<\xE4\xBD\xA0\xE7\x9A\x84 OpenCode API Key>\"" + b"model = \"glm-5.2\"\nmodel_provider = \"opencode-go\"\nmodel_reasoning_effort = \"high\"\ndisable_response_storage = true\nmodel_catalog_json = \"~/.codex/models.json\"\n\n[model_providers.opencode-go]\nname = \"OpenCode Go\"\nbase_url = \"https://opencode.ai/zen/go/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"<\xE4\xBD\xA0\xE7\x9A\x84 OpenCode API Key>\"" + ); + assert_eq!( + OPENROUTER_CONFIG, + b"model = \"openai/gpt-5.6-sol\"\nmodel_provider = \"openrouter\"\nmodel_reasoning_effort = \"high\"\ndisable_response_storage = true\n\n[model_providers.openrouter]\nname = \"OpenRouter\"\nbase_url = \"https://openrouter.ai/api/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"<\xE4\xBD\xA0\xE7\x9A\x84 OpenRouter API Key>\"" + ); + assert_eq!( + MIMO_CONFIG, + b"model = \"mimo-v2.5-pro\"\nmodel_provider = \"mimo\"\nmodel_reasoning_effort = \"high\"\nmodel_supports_reasoning_summaries = true\nmodel_reasoning_summary = \"none\"\nmodel_context_window = 1048576\nweb_search = \"disabled\"\nmodel_catalog_json = \"~/.codex/models.json\"\n\n[model_providers.mimo]\nname = \"mimo\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"<\xE4\xBD\xA0\xE7\x9A\x84 MiMo API Key>\"" ); } @@ -164,8 +207,144 @@ mod tests { assert_eq!(count(ZHIPU_MODELS, b"\r\n"), 72); assert_eq!(MINIMAX_CATALOG.len(), 953); assert_eq!(count(MINIMAX_CATALOG, b"\r\n"), 25); - // OpenAI Code 无官方模板:models.json 保留为空入口 - assert_eq!(template(KIND_OPENCODE).unwrap().catalog.unwrap().1.len(), 0); + // OpenCode 目录为构造产物(无官方文件),不做字节级快照; + // 内容由下方 opencode_catalog 结构断言覆盖 + } + + #[test] + fn opencode_catalog_mirrors_ccswitch_model_metadata() { + let catalog: serde_json::Value = serde_json::from_slice(OPENCODE_MODELS).unwrap(); + let models = catalog["models"].as_array().unwrap(); + let slugs: Vec<&str> = models + .iter() + .map(|model| model["slug"].as_str().unwrap()) + .collect(); + assert_eq!( + slugs, + [ + "glm-5.2", + "glm-5.1", + "kimi-k2.7-code", + "deepseek-v4-pro", + "deepseek-v4-flash", + "mimo-v2.5-pro" + ] + ); + + // 逐模型上下文窗口 + 推理档位(数据源:cc-switch 预设镜像的 models.dev) + let by_slug = |slug: &str| { + models + .iter() + .find(|model| model["slug"] == slug) + .unwrap() + .clone() + }; + let glm = by_slug("glm-5.2"); + assert_eq!(glm["context_window"], 204_800); + assert_eq!(glm["default_reasoning_level"], "high"); + let efforts: Vec<&str> = glm["supported_reasoning_levels"] + .as_array() + .unwrap() + .iter() + .map(|level| level["effort"].as_str().unwrap()) + .collect(); + assert_eq!(efforts, ["high", "max"]); + + let flash = by_slug("deepseek-v4-flash"); + assert_eq!(flash["context_window"], 1_048_576); + let efforts: Vec<&str> = flash["supported_reasoning_levels"] + .as_array() + .unwrap() + .iter() + .map(|level| level["effort"].as_str().unwrap()) + .collect(); + assert_eq!(efforts, ["low", "high", "max"]); + + // Codex 目录解析器必填字段(缺失会拒载整个文件) + for model in models { + assert!(model.get("base_instructions").is_some()); + assert!(model.get("supports_reasoning_summaries").is_some()); + } + + // 官方目录(zhipu/minimax)的完整 21 字段集,缺字段会退化 Codex 的能力声明 + for field in [ + "slug", + "display_name", + "description", + "default_reasoning_level", + "supported_reasoning_levels", + "shell_type", + "visibility", + "supported_in_api", + "priority", + "base_instructions", + "supports_reasoning_summaries", + "default_reasoning_summary", + "support_verbosity", + "apply_patch_tool_type", + "truncation_policy", + "context_window", + "max_context_window", + "effective_context_window_percent", + "supports_parallel_tool_calls", + "experimental_supported_tools", + "input_modalities", + ] { + if field == "default_reasoning_level" || field == "supported_reasoning_levels" { + // 无 effort 档位的模型(glm-5.1 等)合法省略这两个可选字段 + continue; + } + for model in models { + assert!( + model.get(field).is_some(), + "{} 缺少字段 {field}", + model["slug"].as_str().unwrap_or("?") + ); + } + } + } + + #[test] + fn mimo_catalog_follows_official_shape() { + // 官方 model-catalogs.json 的关键形状:拒绝 freeform 自定义工具 + // (无 apply_patch_tool_type)、联网搜索关闭、推理档位 none/high + let catalog: serde_json::Value = serde_json::from_slice(MIMO_MODELS).unwrap(); + let models = catalog["models"].as_array().unwrap(); + assert_eq!(models.len(), 2); + for model in models { + assert!(model.get("apply_patch_tool_type").is_none()); + assert_eq!(model["supports_search_tool"], false); + assert_eq!(model["supports_reasoning_summaries"], true); + assert_eq!(model["context_window"], 1_048_576); + let efforts: Vec<&str> = model["supported_reasoning_levels"] + .as_array() + .unwrap() + .iter() + .map(|level| level["effort"].as_str().unwrap()) + .collect(); + assert_eq!(efforts, ["none", "high"]); + } + // v2.5 支持图片输入且原图高清,pro 纯文本 + let by_slug = |slug: &str| { + models + .iter() + .find(|model| model["slug"] == slug) + .unwrap() + .clone() + }; + assert_eq!( + by_slug("mimo-v2.5")["input_modalities"].to_string(), + r#"["text","image"]"# + ); + assert_eq!(by_slug("mimo-v2.5")["supports_image_detail_original"], true); + assert_eq!( + by_slug("mimo-v2.5-pro")["input_modalities"].to_string(), + r#"["text"]"# + ); + assert_eq!( + by_slug("mimo-v2.5-pro")["supports_image_detail_original"], + false + ); } #[test] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a57dc84..5bcb3d2 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4,6 +4,7 @@ use crate::auth::codex_oauth::{ parse_external_auth_json, AuthStatus, CodexOAuthError, CodexOAuthManager, CodexOAuthState, DeviceCodeResponse, ManagedAccount, }; +use crate::builtin; use crate::codex::config as codex_config; use crate::error::{app_err, AppResult}; use crate::models::{ @@ -235,6 +236,13 @@ pub fn get_builtin_catalog( state.get_builtin_catalog(&kind) } +// 内置供应商的 config.toml 模板原文:前端创建页预览的唯一来源(单源,避免与前端 fragment 双份维护) +#[tauri::command] +pub fn get_builtin_config(kind: String) -> AppResult { + let template = builtin::template(&kind)?; + Ok(String::from_utf8_lossy(template.config).into_owned()) +} + #[tauri::command] // 参数个数受前端 IPC 调用约束(一次性提交 config/catalog/auth 三件套),不宜拆结构体 #[allow(clippy::too_many_arguments)] @@ -299,6 +307,15 @@ pub async fn test_provider_connection( crate::services::test_provider_connection(&base_url, &api_key).await } +// 获取供应商可用模型 ID 列表(OpenAI 兼容 GET /models) +#[tauri::command] +pub async fn fetch_provider_models( + base_url: String, + api_key: String, +) -> Result, String> { + crate::services::fetch_models(&base_url, &api_key).await +} + #[tauri::command] pub async fn get_profile_balance( id: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e788030..55c2e78 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -44,8 +44,10 @@ pub fn run() { commands::add_builtin_profile, commands::add_custom_profile, commands::get_builtin_catalog, + commands::get_builtin_config, commands::test_profile_connection, commands::test_provider_connection, + commands::fetch_provider_models, commands::get_profile_balance, commands::export_database, commands::export_database_to, diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index bf2e264..6edb2d6 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -21,6 +21,7 @@ mod accounts; mod apply; mod connections; mod mcp; +mod model_fetch; mod plugin_net; mod plugins; mod profile_config; @@ -29,6 +30,7 @@ mod settings; mod storage; pub use connections::{test_provider_connection, ProfileBalance, ProfileConnectionResult}; +pub use model_fetch::fetch_models; pub use plugins::{ MarketplacePlugin, PluginCandidate, PluginMarketplace, PluginPreview, PluginSkill, PluginSummary, PluginUpdate, SkillCandidate, SkillSummary, diff --git a/src-tauri/src/services/model_fetch.rs b/src-tauri/src/services/model_fetch.rs new file mode 100644 index 0000000..2028eed --- /dev/null +++ b/src-tauri/src/services/model_fetch.rs @@ -0,0 +1,209 @@ +//! 模型列表获取服务 +//! +//! 通过 OpenAI 兼容的 GET /models 端点拉取供应商可用模型列表。 +//! URL 候选策略参考 cc-switch 的 model_fetch.rs,按 CGswitch 只做 +//! Responses(原生)直连的场景做了简化:无 Anthropic/Google 协议头, +//! 无兼容子路径剥离。 + +use serde::Deserialize; + +/// OpenAI 兼容的 /models 响应格式 +#[derive(Debug, Deserialize)] +struct ModelsResponse { + data: Option>, +} + +#[derive(Debug, Deserialize)] +struct ModelEntry { + id: String, +} + +const FETCH_TIMEOUT_SECS: u64 = 15; +/// 404/405 响应体截断长度:避免把几十 KB 的 HTML 404 页整页保留到错误串里。 +const ERROR_BODY_MAX_CHARS: usize = 512; + +/// 获取供应商的可用模型 ID 列表(Bearer 认证,按候选 URL 顺序尝试)。 +pub async fn fetch_models(base_url: &str, api_key: &str) -> Result, String> { + let candidates = build_models_url_candidates(base_url)?; + if api_key.trim().is_empty() { + return Err("请先填写 API 密钥再获取模型列表".to_string()); + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS)) + .build() + .map_err(|error| format!("构建 HTTP 客户端失败: {error}"))?; + + let mut last_err: Option = None; + for url in &candidates { + let response = match client.get(url).bearer_auth(api_key).send().await { + Ok(response) => response, + Err(error) => return Err(format!("请求失败: {error}")), + }; + + let status = response.status(); + if status.is_success() { + let parsed: ModelsResponse = response + .json() + .await + .map_err(|error| format!("响应解析失败: {error}"))?; + let mut models: Vec = parsed + .data + .unwrap_or_default() + .into_iter() + .map(|entry| entry.id) + .collect(); + models.sort(); + return Ok(models); + } + + let body = truncate_body(redact(&response.text().await.unwrap_or_default(), api_key)); + // 404/405:路径猜错,试下一候选;其他错误(401/403/5xx)直接返回 + if status == reqwest::StatusCode::NOT_FOUND + || status == reqwest::StatusCode::METHOD_NOT_ALLOWED + { + last_err = Some(format!("HTTP {status}: {body}")); + continue; + } + return Err(format!("HTTP {status}: {body}")); + } + + Err(format!( + "所有候选端点均失败: {}", + last_err.unwrap_or_else(|| "无候选".to_string()) + )) +} + +/// 已知供应商的模型列表端点特例:base_url 与模型端点不同族、无法从 URL 推导。 +/// 智谱的 Codex Responses 端点是 `/api/v1`,而通用 OpenAI 兼容端点 +/// (含模型列表)在 `/api/paas/v4`。 +const KNOWN_MODELS_URLS: &[(&str, &str)] = &[( + "open.bigmodel.cn", + "https://open.bigmodel.cn/api/paas/v4/models", +)]; + +/// 构造模型列表端点的候选 URL: +/// - 命中已知供应商映射 → 特例端点排在最前(推导候选留作兜底) +/// - baseURL 以版本段 `/v{N}` 结尾(`/v1`、OpenCode `/zen/go/v1` 等)→ `{base}/models` +/// (版本号已在路径里,不能再补 `/v1`,否则 `.../v4/v1/models` → 404) +/// - 版本段非 `/v1` 时追加 `/v1/models` 兜底;无版本段 → `{base}/v1/models` +pub fn build_models_url_candidates(base_url: &str) -> Result, String> { + let trimmed = base_url.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return Err("请求地址为空".to_string()); + } + + let mut candidates = Vec::new(); + for (host, url) in KNOWN_MODELS_URLS { + if trimmed.contains(host) { + candidates.push((*url).to_string()); + } + } + if ends_with_version_segment(trimmed) { + candidates.push(format!("{trimmed}/models")); + if !trimmed.ends_with("/v1") { + candidates.push(format!("{trimmed}/v1/models")); + } + } else { + candidates.push(format!("{trimmed}/v1/models")); + } + // 线性去重(候选最多 3 条),保持首次出现顺序 + let mut unique: Vec = Vec::with_capacity(candidates.len()); + for url in candidates { + if !unique.contains(&url) { + unique.push(url); + } + } + Ok(unique) +} + +/// 是否以 OpenAI 风格的版本段 `/v{N}` 结尾(`/v1`、`.../paas/v4`)。 +fn ends_with_version_segment(url: &str) -> bool { + let last = url.rsplit('/').next().unwrap_or(""); + last.strip_prefix('v') + .is_some_and(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())) +} + +fn redact(body: &str, secret: &str) -> String { + if secret.is_empty() { + body.to_string() + } else { + body.replace(secret, "[REDACTED]") + } +} + +fn truncate_body(body: String) -> String { + if body.chars().count() <= ERROR_BODY_MAX_CHARS { + body + } else { + let mut truncated: String = body.chars().take(ERROR_BODY_MAX_CHARS).collect(); + truncated.push('…'); + truncated + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn candidates_plain_root_appends_v1() { + let c = build_models_url_candidates("https://api.siliconflow.cn").unwrap(); + assert_eq!(c, vec!["https://api.siliconflow.cn/v1/models"]); + } + + #[test] + fn candidates_trailing_slash_is_trimmed() { + let c = build_models_url_candidates("https://api.example.com/").unwrap(); + assert_eq!(c, vec!["https://api.example.com/v1/models"]); + } + + #[test] + fn candidates_v1_suffix_uses_models_directly() { + // 以 /v1 结尾的端点:版本段已就位,拼 /models + let c = build_models_url_candidates("https://opencode.ai/zen/go/v1").unwrap(); + assert_eq!(c, vec!["https://opencode.ai/zen/go/v1/models"]); + let c = build_models_url_candidates("https://api.minimaxi.com/v1").unwrap(); + assert_eq!(c, vec!["https://api.minimaxi.com/v1/models"]); + } + + #[test] + fn candidates_non_v1_version_keeps_fallback() { + let c = build_models_url_candidates("https://open.bigmodel.cn/api/paas/v4").unwrap(); + assert_eq!( + c, + vec![ + "https://open.bigmodel.cn/api/paas/v4/models", + "https://open.bigmodel.cn/api/paas/v4/v1/models", + ] + ); + } + + #[test] + fn candidates_rejects_empty_base() { + assert!(build_models_url_candidates(" ").is_err()); + } + + #[test] + fn candidates_zhipu_uses_known_models_endpoint_first() { + // 智谱 Responses 端点 /api/v1 与通用端点 /api/paas/v4 不同族: + // 特例端点排最前,推导候选(404)留作兜底 + let c = build_models_url_candidates("https://open.bigmodel.cn/api/v1").unwrap(); + assert_eq!( + c, + vec![ + "https://open.bigmodel.cn/api/paas/v4/models", + "https://open.bigmodel.cn/api/v1/models", + ] + ); + } + + #[test] + fn error_body_is_truncated_and_redacted() { + let long = "x".repeat(600).replace("xxxxx", "key-1"); + let out = truncate_body(redact(&format!("key-1{long}"), "key-1")); + assert!(out.chars().count() <= ERROR_BODY_MAX_CHARS + 1); + assert!(!out.contains("key-1")); + assert!(out.contains("[REDACTED]")); + } +} diff --git a/src/api/index.ts b/src/api/index.ts index 297ec59..188357f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -35,6 +35,8 @@ function call(command: string, args?: Record): Promise { export const api = { getState: () => call("get_state"), getCodexStatus: () => call("get_codex_status"), + fetchProviderModels: (baseUrl: string, apiKey: string) => + call("fetch_provider_models", { baseUrl, apiKey }), captureProfile: (name: string) => call("capture_profile", { name }), addBuiltinProfile: ( kind: string, @@ -62,6 +64,7 @@ export const api = { authText, }), getBuiltinCatalog: (kind: string) => call("get_builtin_catalog", { kind }), + getBuiltinConfig: (kind: string) => call("get_builtin_config", { kind }), testProfileConnection: (id: string, baseUrl?: string, apiKey?: string) => call("test_profile_connection", { id, baseUrl, apiKey }), // 创建态表单测试:供应商尚未保存,直接用表单里的地址/密钥 diff --git a/src/api/web-mock.ts b/src/api/web-mock.ts index 69f58bd..aa7b023 100644 --- a/src/api/web-mock.ts +++ b/src/api/web-mock.ts @@ -1,4 +1,4 @@ -import { balanceQueryProviders, builtinPresetByKind } from "../presets"; +import { balanceQueryProviders, builtinHasCatalog, builtinPresetByKind, type BuiltinPreset } from "../presets"; import type { AppState, DatabaseBackupInfo, @@ -16,7 +16,6 @@ import type { ProfileSummary, Settings, } from "../types"; -import { stripTomlQuotes } from "../utils"; const webProfiles: ProfileSummary[] = [ { @@ -538,6 +537,14 @@ function renderMcpFragmentWeb(spec: McpServerSpec): string { return lines.join("\n") + "\n"; } +// 浏览器调试模式不查后端命令,用预设的展示元数据合成最小 config 模板 +// (正式运行时由后端 builtin 模板给出完整原文) +function mockBuiltinFragment(preset: BuiltinPreset, apiKey: string): string { + return preset.provider + ? `model = "${preset.model}"\nmodel_provider = "${preset.provider}"\nmodel_reasoning_effort = "high"\n\n[model_providers.${preset.provider}]\nname = "${preset.provider}"\nbase_url = "${preset.base_url}"\nwire_api = "responses"\nexperimental_bearer_token = "${apiKey || ""}"` + : `model = "${preset.model}"\nmodel_reasoning_effort = "high"`; +} + export async function webInvoke(command: string, args?: Record): Promise { await new Promise((resolve) => setTimeout(resolve, 120)); switch (command) { case "get_state": @@ -574,6 +581,8 @@ export async function webInvoke(command: string, args?: Record = { model: JSON.stringify(preset.model), model_reasoning_effort: '"high"' }; const now = new Date().toISOString(); const profile: ProfileSummary = { id: `profile-${Date.now()}`, @@ -583,7 +592,7 @@ export async function webInvoke(command: string, args?: Record(command: string, args?: Record(command: string, args?: Record - + { label: string; value: T; @@ -53,7 +56,10 @@ export function AppSelect({ const menuHeight = menu.scrollHeight; const below = window.innerHeight - rect.bottom - gap; const above = rect.top - gap; - const nextPlacement = below < menuHeight && above > below ? "top" : "bottom"; + // 翻转判定用实际会展示的高度(CSS max-height 封顶后的值),而不是内容 + // 完整高度 scrollHeight:后者会高估需求,导致下方空间明明够却向上翻转 + const effectiveHeight = Math.min(menuHeight, MENU_MAX_HEIGHT); + const nextPlacement = below < effectiveHeight && above > below ? "top" : "bottom"; setPlacement(nextPlacement); setMenuStyle({ left: `${rect.left}px`, @@ -63,13 +69,39 @@ export function AppSelect({ }; updatePosition(); window.addEventListener("resize", updatePosition); - window.addEventListener("scroll", updatePosition, true); return () => { window.removeEventListener("resize", updatePosition); - window.removeEventListener("scroll", updatePosition, true); }; }, [open, options.length]); + // 展开期间的背景滚动控制: + // 1. 菜单外发生滚动(容器滚轮/拖动)→ 直接收起,避免 fixed 菜单跟随触发器跳跑 + useEffect(() => { + if (!open) return; + const onBackgroundScroll = (event: Event) => { + if (event.target instanceof Node && menuRef.current?.contains(event.target)) return; + setOpen(false); + }; + window.addEventListener("scroll", onBackgroundScroll, true); + return () => window.removeEventListener("scroll", onBackgroundScroll, true); + }, [open]); + + // 2. 菜单自身的滚轮不穿透:内容不满或已滚到边界时拦下,背景纹丝不动 + // (React 的 onWheel 是 passive 的,preventDefault 必须用原生 non-passive 监听) + useEffect(() => { + if (!open) return; + const menu = menuRef.current; + if (!menu) return; + const onMenuWheel = (event: WheelEvent) => { + const { scrollTop, scrollHeight, clientHeight } = menu; + const canScroll = scrollHeight > clientHeight; + const atEdge = event.deltaY < 0 ? scrollTop <= 0 : scrollTop + clientHeight >= scrollHeight; + if (!canScroll || atEdge) event.preventDefault(); + }; + menu.addEventListener("wheel", onMenuWheel, { passive: false }); + return () => menu.removeEventListener("wheel", onMenuWheel); + }, [open]); + const selectOption = (option: SelectOption) => { onChange(option.value); setOpen(false); diff --git a/src/features/profiles/ProfileEdit.tsx b/src/features/profiles/ProfileEdit.tsx index ba8e21a..7138eb5 100644 --- a/src/features/profiles/ProfileEdit.tsx +++ b/src/features/profiles/ProfileEdit.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, Eye, EyeOff, ExternalLink, FileBraces, Info, KeyRound, Monitor, Pencil, Save, Settings, Wifi } from "lucide-react"; +import { ArrowLeft, Download, Eye, EyeOff, ExternalLink, FileBraces, Info, KeyRound, Monitor, Pencil, Save, Settings, Webhook, Wifi } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { api } from "../../api"; import { useFeedback } from "../../app/Feedback"; @@ -9,12 +9,13 @@ import { LoadingSpinner } from "../../components/LoadingSpinner"; import { ProfileIconTile } from "../../components/ProfileIconTile"; import { balanceQueryProviders, + builtinHasCatalog, builtinPresets, customCatalogTemplate, customConfigTemplate, usageQueryProviders, } from "../../presets"; -import { patchProviderFields, readProviderFields, resolveAuthSource, withMcpSection } from "./profileEditText"; +import { patchModelValue, patchProviderFields, readModelValue, readProviderFields, resolveAuthSource, withMcpSection } from "./profileEditText"; import type { EditorDiagnosticSummary, ManagedAccount, ProfileDetail, ProfileSummary } from "../../types"; import ProfileIconEdit from "./ProfileIconEdit"; @@ -54,6 +55,9 @@ export default function ProfileEdit({ profile, create = false, onBack, onChanged const [baseUrl, setBaseUrl] = useState(""); const [apiKey, setApiKey] = useState(""); const [showApiKey, setShowApiKey] = useState(false); + const [modelValue, setModelValue] = useState(""); + const [fetchedModels, setFetchedModels] = useState([]); + const [fetchingModels, setFetchingModels] = useState(false); const [adminUrl, setAdminUrl] = useState(""); const [authAccounts, setAuthAccounts] = useState([]); const [boundAccountId, setBoundAccountId] = useState(null); @@ -80,6 +84,7 @@ export default function ProfileEdit({ profile, create = false, onBack, onChanged const [mcpSection, setMcpSection] = useState(""); const initialized = useRef(false); const authPreviewRequest = useRef(0); + const presetTemplateRequest = useRef(0); const editorRef = useRef(null); const selectedPreset = useMemo(() => builtinPresets.find((preset) => preset.kind === presetKind) ?? null, [presetKind]); @@ -90,6 +95,10 @@ export default function ProfileEdit({ profile, create = false, onBack, onChanged const showLongContextOverride = isOfficial; const supportsBalance = isOfficial || balanceQueryProviders.has(detail?.provider ?? ""); const isUsageProvider = usageQueryProviders.has(detail?.provider ?? ""); + // 创建态下预设的 config 原文统一从后端取(单源真相),避免与 Rust 模板双份维护。 + // 与 configText 同源同时设置(selectPreset 内 await 后一起 set),防止异步晚到 + // 触发 configText !== liveConfigFragment 的误判 + const [presetFragment, setPresetFragment] = useState(""); const authSource = create ? boundAccountId ? "oauth" : "desktop" : resolveAuthSource(detail); @@ -117,7 +126,7 @@ export default function ProfileEdit({ profile, create = false, onBack, onChanged if (showAuthTab) list.push({ id: "auth", label: "auth.json" }); return list; }, [catalogFileName, liveCatalogPath, showAuthTab]); - const baseFragment = create ? selectedPreset?.fragment ?? "" : detail?.config_fragment ?? ""; + const baseFragment = create ? presetFragment : detail?.config_fragment ?? ""; const liveConfigFragment = useMemo(() => { if (!baseFragment) return ""; return withMcpSection(patchProviderFields(baseFragment, baseUrl, apiKey), mcpSection); @@ -183,9 +192,11 @@ export default function ProfileEdit({ profile, create = false, onBack, onChanged setName("自定义供应商"); setSelectedIcon("custom"); setConfigText(withMcpSection(customConfigTemplate, initialMcpSection)); + setPresetFragment(customConfigTemplate); setCatalogText(customCatalogTemplate); setConfigInitial(withMcpSection(customConfigTemplate, initialMcpSection)); setCatalogInitial(customCatalogTemplate); + setModelValue("your-model"); } else if (profile) { try { const loaded = await api.getProfile(profile.id); @@ -200,6 +211,7 @@ export default function ProfileEdit({ profile, create = false, onBack, onChanged setAuthInitial(loaded.raw_auth ?? ""); setBaseUrl(loaded.base_url ?? ""); setApiKey(loaded.api_key ?? ""); + setModelValue(readModelValue(loaded.raw_config ?? loaded.config_fragment) ?? ""); setAdminUrl(loaded.admin_url ?? ""); setSelectedIcon(loaded.icon); setBoundAccountId(loaded.account_id); @@ -237,8 +249,7 @@ export default function ProfileEdit({ profile, create = false, onBack, onChanged if (create && !isCustom) setCatalogText(""); return; } - const preset = selectedPreset; - if (!preset?.model_values.model_catalog_json) { + if (!builtinHasCatalog(presetKind)) { setCatalogText(""); return; } @@ -256,6 +267,22 @@ export default function ProfileEdit({ profile, create = false, onBack, onChanged }); }, [apiKey, baseUrl]); + // 表单模型值 ↔ 编辑器顶层 model 行 双向同步(与地址/密钥同一模式) + useEffect(() => { + if (!initialized.current) return; + setConfigText((current) => { + const next = patchModelValue(current, modelValue); + return next === current ? current : next; + }); + }, [modelValue]); + + useEffect(() => { + if (!initialized.current) return; + const model = readModelValue(configText); + if (model !== null && model !== modelValue) setModelValue(model); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [configText]); + useEffect(() => { if (!initialized.current) return; const fields = readProviderFields(configText); @@ -278,9 +305,20 @@ export default function ProfileEdit({ profile, create = false, onBack, onChanged if (!patchingSystemProxy) setSystemProxyEnabled(hasSystemProxyOverride(configText)); }, [configText, patchingSystemProxy]); - const selectPreset = (kind: string) => { + const selectPreset = async (kind: string) => { const preset = builtinPresets.find((item) => item.kind === kind); if (!preset) return; + // 模板取回后才一次性更新全部状态:避免"表单已切、configText 未切"的中间渲染 + // 触发 configText !== liveConfigFragment 的 configTouched 误判 + const requestId = ++presetTemplateRequest.current; + let template: string; + try { + template = kind === "custom" ? customConfigTemplate : await api.getBuiltinConfig(kind); + } catch (error) { + if (requestId === presetTemplateRequest.current) feedback.error(`读取内置模板失败:${String(error)}`); + return; + } + if (requestId !== presetTemplateRequest.current) return; setPresetKind(kind); if (kind !== "chatgpt") setBoundAccountId(null); setConfigTouched(false); @@ -290,9 +328,12 @@ export default function ProfileEdit({ profile, create = false, onBack, onChanged setName(preset.name); setBaseUrl(preset.base_url); setApiKey(""); + setModelValue(preset.model); + setFetchedModels([]); setAdminUrl(preset.admin_url ?? ""); setSelectedIcon(preset.icon); - const nextConfig = withMcpSection(patchProviderFields(preset.fragment, preset.base_url, ""), mcpSection); + setPresetFragment(template); + const nextConfig = withMcpSection(patchProviderFields(template, preset.base_url, ""), mcpSection); setConfigText(nextConfig); setConfigInitial(nextConfig); setLongContextEnabled(kind === "chatgpt" && hasLongContextOverride(nextConfig)); @@ -361,6 +402,20 @@ export default function ProfileEdit({ profile, create = false, onBack, onChanged finally { setTesting(false); } }; + const fetchModelList = async () => { + if (fetchingModels) return; + if (!baseUrl.trim()) { feedback.warning("请填写调用地址"); return; } + if (!apiKey.trim()) { feedback.warning("请先填写 API 密钥"); return; } + setFetchingModels(true); + try { + const models = await api.fetchProviderModels(baseUrl.trim(), apiKey.trim()); + setFetchedModels(models); + if (models.length === 0) feedback.info("接口未返回任何模型"); + else feedback.success(`获取到 ${models.length} 个模型,可在右侧下拉选择`); + } catch (error) { feedback.error(`获取失败:${String(error)}`); } + finally { setFetchingModels(false); } + }; + const saveIcon = async (icon: string | null) => { if (saving) return; setSaving(true); @@ -428,11 +483,11 @@ export default function ProfileEdit({ profile, create = false, onBack, onChanged {loadError ?

{loadError}

: null}
{create ?
选择供应商
- {builtinPresets.map((preset) => )} + {builtinPresets.map((preset) => )}
: null}
名称
setName(event.target.value)} />
- {showProviderFields ? <> setBaseUrl(event.target.value)} />
API 密钥{isOpenCode && create ? : null}
setApiKey(event.target.value)} />
{isOpenCode && create ?

使用此链接订阅 OpenCode Go,首月只需 $5,并可获得额外的 $5 额度!

: null} : null} + {showProviderFields ? <>
setBaseUrl(event.target.value)} />
API 密钥{isOpenCode && create ? : null}
setApiKey(event.target.value)} />
模型{fetchedModels.length > 0 ? {fetchedModels.length} 个可用 : null}
setModelValue(event.target.value)} />{fetchedModels.length > 0 ?
({ label: id, value: id }))} onChange={(value) => setModelValue(value)} placeholder="选择模型" />
: null}
{isOpenCode && create ?

使用此链接订阅 OpenCode Go,首月只需 $5,并可获得额外的 $5 额度!

: null} : null} {isOfficial ?
登录方式
{create ? : authSource === "oauth" ? :
}
: null} {(!create || selectedPreset?.base_url) ?
官网地址
setAdminUrl(event.target.value)} />
: null} {!create && supportsBalance ?
{isOfficial ? "ChatGPT 额度显示" : isUsageProvider ? "用量查询" : "余额/用量查询"}
窗口激活时自动刷新,点击数字手动刷新
void toggleBalance(value)} />
: null} diff --git a/src/features/profiles/profileEditText.test.ts b/src/features/profiles/profileEditText.test.ts index 8f677a4..a33e48b 100644 --- a/src/features/profiles/profileEditText.test.ts +++ b/src/features/profiles/profileEditText.test.ts @@ -1,5 +1,77 @@ import { describe, expect, it } from "vitest"; -import { patchProviderFields, readProviderFields, resolveAuthSource, withMcpSection } from "./profileEditText"; +import { patchModelValue, patchProviderFields, readModelValue, readProviderFields, resolveAuthSource, withMcpSection } from "./profileEditText"; + +describe("model value read/patch", () => { + const source = [ + 'model = "glm-5.2"', + 'model_provider = "ZAI"', + 'model_reasoning_effort = "high"', + "", + "[model_providers.ZAI]", + 'name = "ZAI"', + ].join("\n"); + + it("reads the top-level model line without touching model_* keys", () => { + expect(readModelValue(source)).toBe("glm-5.2"); + expect(readModelValue('model_reasoning_effort = "high"\n')).toBeNull(); + expect(readModelValue("model_provider = \"x\"\n")).toBeNull(); + }); + + it("patches the model line in place preserving indentation", () => { + const patched = patchModelValue(source, "glm-5.3"); + expect(patched).toContain('model = "glm-5.3"'); + expect(patched).toContain('model_provider = "ZAI"'); + expect(patched).toContain('model_reasoning_effort = "high"'); + }); + + it("inserts a model line after model_provider when missing", () => { + const noModel = 'model_provider = "ZAI"\nmodel_reasoning_effort = "high"\n'; + const patched = patchModelValue(noModel, "glm-5.2"); + expect(patched).toBe('model_provider = "ZAI"\nmodel = "glm-5.2"\nmodel_reasoning_effort = "high"\n'); + }); + + it("round-trips through the editor unchanged when values agree", () => { + // 表单 → 编辑器 no-op:同值重写产出相同文本(防双向同步死循环) + expect(patchModelValue(source, "glm-5.2")).toBe(source); + }); + + it("removes the model line when the form value is empty", () => { + const patched = patchModelValue(source, ""); + expect(patched).not.toContain("model ="); + expect(patched).toContain('model_provider = "ZAI"'); + }); + + it("replaces the existing model line when model_provider comes first", () => { + // 回归:智谱的配置 model_provider 在 model 之前,旧实现逐行扫描先命中 + // provider 就插入新行,不去替换后面已有的 model 行 → 每次切换多出一行 + // model(TOML 重复键非法) + const providerFirst = [ + 'model_provider = "ZAI"', + 'model = "glm-5.3"', + 'model_reasoning_effort = "max"', + ].join("\n"); + const patched = patchModelValue(providerFirst, "glm-5"); + expect(patched).toBe([ + 'model_provider = "ZAI"', + 'model = "glm-5"', + 'model_reasoning_effort = "max"', + ].join("\n")); + }); + + it("collapses duplicate model lines left by the old defect", () => { + const polluted = [ + 'model_provider = "ZAI"', + 'model = "glm-5"', + 'model = "glm-5-turbo"', + 'model = "glm-5.3"', + 'model_reasoning_effort = "high"', + ].join("\n"); + const patched = patchModelValue(polluted, "glm-5.3"); + expect(patched.match(/^model = /gm)?.length).toBe(1); + expect(patched).toContain('model = "glm-5.3"'); + expect(patched).toContain('model_provider = "ZAI"'); + }); +}); describe("resolveAuthSource", () => { it("keeps the backend's explicit null for third-party profiles", () => { diff --git a/src/features/profiles/profileEditText.ts b/src/features/profiles/profileEditText.ts index 3255079..66afcc3 100644 --- a/src/features/profiles/profileEditText.ts +++ b/src/features/profiles/profileEditText.ts @@ -141,3 +141,48 @@ export function patchProviderFields(text: string, baseUrl: string, apiKey: strin export function withMcpSection(base: string, mcpSection: string): string { return mcpSection ? `${base.trimEnd()}\n\n${mcpSection.trimEnd()}\n` : base; } + +/** 读取顶层 `model = "..."` 的值(剥引号);无该行返回 null。 + * `^model\s*=` 不匹配 model_provider / model_reasoning_effort 等前缀键。 */ +export function readModelValue(text: string): string | null { + for (const line of text.split("\n")) { + const match = /^model\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s#]+))/.exec(line.trim()); + if (match) return match[1] ?? match[2] ?? match[3] ?? ""; + } + return null; +} + +/** + * 把表单的模型值写回顶层 `model = "..."` 行: + * - 有 model 行 → 替换第一处;后续重复的 model 行(历史缺陷产生的非法重复键)一并收敛 + * - 没有任何 model 行时才插到 `model_provider` 行后(仍无则不动文本) + * 注意不能逐行边扫边插:智谱等配置 model_provider 排在 model 前, + * 先遇到 provider 就插入会造出重复的 model 键(TOML 非法)。 + */ +export function patchModelValue(text: string, model: string): string { + const value = model.trim(); + const lines = text.split("\n"); + const out: string[] = []; + let replaced = false; + for (const line of lines) { + if (/^model\s*=/.test(line.trim())) { + if (!replaced && value !== "") { + const indent = line.slice(0, line.length - line.trimStart().length); + out.push(`${indent}model = ${JSON.stringify(value)}`); + } + // 首个 model 行被替换(或 value 为空被删除)后,其余重复行一律丢弃 + replaced = true; + continue; + } + out.push(line); + } + if (replaced) return out.join("\n"); + if (value === "") return text; + + const providerIndex = out.findIndex((line) => /^model_provider\s*=/.test(line.trim())); + if (providerIndex === -1) return text; + const provider = out[providerIndex]; + const indent = provider.slice(0, provider.length - provider.trimStart().length); + out.splice(providerIndex + 1, 0, `${indent}model = ${JSON.stringify(value)}`); + return out.join("\n"); +} diff --git a/src/icons.ts b/src/icons.ts index 912af13..cdca899 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -12,9 +12,10 @@ const LABELS: Record = { deepseek: "DeepSeek", minimax: "MiniMax", opencode: "OpenCode", + openrouter: "OpenRouter", }; -const THEME_INVERTED_IDS = new Set(["openai-chatgpt", "opencode", "xiaomi-mimo"]); +const THEME_INVERTED_IDS = new Set(["openai-chatgpt", "opencode", "xiaomi-mimo", "openrouter"]); export interface ProviderIcon { id: string; diff --git a/src/presets.test.ts b/src/presets.test.ts index 0e671ab..c930e97 100644 --- a/src/presets.test.ts +++ b/src/presets.test.ts @@ -1,4 +1,37 @@ import { describe, expect, it } from "vitest"; +import { customCatalogTemplate } from "./presets"; + +describe("customCatalogTemplate", () => { + it("follows the Codex catalog schema with parser-required fields", () => { + // 回归:旧模板用 id/name(Codex 目录格式是 slug),且 base_instructions 与 + // supports_reasoning_summaries 缺失会让 Codex 拒载整个目录文件 + const catalog = JSON.parse(customCatalogTemplate) as { models: Array> }; + expect(catalog.models.length).toBeGreaterThan(0); + for (const model of catalog.models) { + expect(model.slug).toBeTruthy(); + expect(model).toHaveProperty("base_instructions"); + expect(model).toHaveProperty("supports_reasoning_summaries"); + expect(model.id).toBeUndefined(); + expect(model.name).toBeUndefined(); + } + }); + + it("carries the full 21-field set of the official catalogs", () => { + // 对照 assets/builtin/zhipu-models.json 的字段集(CCswitch 与官方目录每条 21 字段) + const expected = [ + "slug", "display_name", "description", "default_reasoning_level", "supported_reasoning_levels", + "shell_type", "visibility", "supported_in_api", "priority", "base_instructions", + "supports_reasoning_summaries", "default_reasoning_summary", "support_verbosity", + "apply_patch_tool_type", "truncation_policy", "context_window", "max_context_window", + "effective_context_window_percent", "supports_parallel_tool_calls", + "experimental_supported_tools", "input_modalities", + ]; + const catalog = JSON.parse(customCatalogTemplate) as { models: Array> }; + for (const field of expected) { + expect(catalog.models[0]).toHaveProperty(field); + } + }); +}); import { balanceChipClass } from "./presets"; describe("balanceChipClass", () => { diff --git a/src/presets.ts b/src/presets.ts index 7dc36d7..26365a6 100644 --- a/src/presets.ts +++ b/src/presets.ts @@ -1,3 +1,5 @@ +// 展示元数据:网格选择时的展示 / provider id 推断 / "测试连通" 按钮可用性判定。 +// config.toml 原文由后端 builtin 模板单一来源,模型目录同。 export interface BuiltinPreset { kind: string; name: string; @@ -6,8 +8,6 @@ export interface BuiltinPreset { base_url: string; admin_url: string | null; model: string; - model_values: Record; - fragment: string; } /** 支持余额/用量查询的供应商(以 provider_id 键控);加供应商时在这里加一行即可 */ @@ -16,7 +16,7 @@ export const balanceQueryProviders = new Set(["deepseek", "minimax", "ZAI"]); /** 文案使用“用量”的供应商;DeepSeek 保持“余额”,ChatGPT 额度单独处理。 */ export const usageQueryProviders = new Set(["minimax", "ZAI"]); -/** 余额/用量胶囊变色(已用 <70% 绿 / 70-89% 橙 / ≥90% 红;负余额红色) */ +/** 余额/用量胶囊变色(已用 <70% 绿 / 70-89 9、橙 / ≥90% 红;负余额红色) */ export function balanceChipClass( usagePercent: number | null, failed: boolean, @@ -31,159 +31,70 @@ export function balanceChipClass( export const customConfigTemplate = `model = "your-model" model_provider = "your-provider" -model_reasoning_effort = "medium" +model_reasoning_effort = "high" +disable_response_storage = true model_catalog_json = "~/.codex/models.json" [model_providers.your-provider] name = "your-provider" +base_url = "https://api.example.com/v1" wire_api = "responses" -experimental_bearer_token = "<你的 API Key>"`; +experimental_bearer_token = ""`; +/** 模型目录按 Codex 官方目录的完整字段集(对照 assets/builtin/zhipu-models.json, + * 每条 21 个字段):slug 是模型标识;base_instructions 与 supports_reasoning_summaries + * 为解析器必填字段,缺失会让 Codex 拒载整个目录文件。 */ export const customCatalogTemplate = `{ "models": [ { - "id": "your-model", - "name": "Your Model" + "slug": "your-model", + "display_name": "Your Model", + "description": "Your Model", + "default_reasoning_level": "high", + "supported_reasoning_levels": [ + { "effort": "low", "description": "Light reasoning" }, + { "effort": "high", "description": "Deep reasoning" } + ], + "shell_type": "shell_command", + "visibility": "list", + "supported_in_api": true, + "priority": 0, + "base_instructions": "", + "supports_reasoning_summaries": true, + "default_reasoning_summary": "none", + "support_verbosity": false, + "apply_patch_tool_type": "freeform", + "truncation_policy": { "mode": "bytes", "limit": 10000 }, + "context_window": 128000, + "max_context_window": 128000, + "effective_context_window_percent": 95, + "supports_parallel_tool_calls": true, + "experimental_supported_tools": [], + "input_modalities": ["text"] } ] }`; export const builtinPresets: BuiltinPreset[] = [ - { - kind: "deepseek", - name: "DeepSeek", - provider: "deepseek", - icon: "deepseek", - base_url: "https://api.deepseek.com/", - admin_url: "https://platform.deepseek.com", - model: "deepseek-v4-flash", - model_values: { - model: '"deepseek-v4-flash"', - model_reasoning_effort: '"high"', - model_catalog_json: '"~/.codex/models.json"', - }, - fragment: [ - 'model = "deepseek-v4-flash"', - 'model_provider = "deepseek"', - 'preferred_auth_method = "apikey"', - 'forced_login_method = "api"', - 'model_reasoning_effort = "high"', - 'model_catalog_json = "~/.codex/models.json"', - "", - "[model_providers.deepseek]", - 'name = "deepseek"', - 'base_url = "https://api.deepseek.com/"', - 'wire_api = "responses"', - 'experimental_bearer_token = "<你的 DeepSeek API Key>"', - ].join("\n"), - }, - { - kind: "minimax", - name: "MiniMax", - provider: "minimax", - icon: "minimax", - base_url: "https://api.minimaxi.com/v1", - admin_url: "https://platform.minimaxi.com", - model: "MiniMax-M3", - model_values: { - model: '"MiniMax-M3"', - model_reasoning_effort: '"high"', - model_catalog_json: '"~/.codex/model-catalogs/custom-catalog.json"', - }, - fragment: [ - 'model = "MiniMax-M3"', - 'model_provider = "minimax"', - "model_context_window = 1000000", - 'model_catalog_json = "~/.codex/model-catalogs/custom-catalog.json"', - "", - "[model_providers.minimax]", - 'name = "MiniMax"', - 'base_url = "https://api.minimaxi.com/v1"', - 'experimental_bearer_token = ""', - 'wire_api = "responses"', - ].join("\n"), - }, - { - kind: "zhipu", - name: "智谱", - provider: "ZAI", - icon: "zhipu", - base_url: "https://open.bigmodel.cn/api/v1", - admin_url: "https://open.bigmodel.cn", - model: "glm-5.3", - model_values: { - model: '"glm-5.3"', - model_reasoning_effort: '"max"', - model_catalog_json: '"~/.codex/models.json"', - }, - fragment: [ - 'model_provider = "ZAI"', - 'model = "glm-5.3"', - 'model_reasoning_effort = "max"', - 'model_catalog_json = "~/.codex/models.json"', - "", - "[model_providers.ZAI]", - 'name = "ZAI"', - 'base_url = "https://open.bigmodel.cn/api/v1"', - 'experimental_bearer_token = ""', - 'wire_api = "responses"', - ].join("\n"), - }, - { - kind: "chatgpt", - name: "ChatGPT", - provider: null, - icon: "openai-chatgpt", - base_url: "", - admin_url: "https://openai.com/chatgpt/pricing", - model: "gpt-5.6", - model_values: { - model: '"gpt-5.6"', - model_reasoning_effort: '"medium"', - }, - fragment: 'model = "gpt-5.6"\nmodel_reasoning_effort = "medium"', - }, - { - kind: "opencode", - name: "OpenCode", - provider: "opencode-go", - icon: "opencode", - base_url: "https://opencode.ai/zen/go/v1", - admin_url: null, - model: "deepseek-v4-flash", - model_values: { - model: '"deepseek-v4-flash"', - model_reasoning_effort: '"high"', - model_catalog_json: '"~/.codex/models.json"', - }, - fragment: [ - 'model = "deepseek-v4-flash"', - 'model_provider = "opencode-go"', - 'preferred_auth_method = "apikey"', - 'forced_login_method = "api"', - 'model_reasoning_effort = "high"', - 'model_catalog_json = "~/.codex/models.json"', - "", - "[model_providers.opencode-go]", - 'name = "OpenCode Go"', - 'base_url = "https://opencode.ai/zen/go/v1"', - 'wire_api = "responses"', - 'experimental_bearer_token = "<你的 OpenCode API Key>"', - ].join("\n"), - }, - { - kind: "custom", - name: "自定义", - provider: null, - icon: "custom", - base_url: "", - admin_url: null, - model: "自定义", - model_values: { model_catalog_json: '"~/.codex/models.json"' }, - fragment: customConfigTemplate, - }, + { kind: "deepseek", name: "DeepSeek", provider: "deepseek", icon: "deepseek", base_url: "https://api.deepseek.com/", admin_url: "https://platform.deepseek.com", model: "deepseek-v4-flash" }, + { kind: "minimax", name: "MiniMax", provider: "minimax", icon: "minimax", base_url: "https://api.minimaxi.com/v1", admin_url: "https://platform.minimaxi.com", model: "MiniMax-M3" }, + { kind: "zhipu", name: "智谱", provider: "ZAI", icon: "zhipu", base_url: "https://open.bigmodel.cn/api/v1", admin_url: "https://open.bigmodel.cn", model: "glm-5.3" }, + { kind: "chatgpt", name: "ChatGPT", provider: null, icon: "openai-chatgpt", base_url: "", admin_url: "https://openai.com/chatgpt/pricing", model: "gpt-5.6" }, + { kind: "opencode", name: "OpenCode", provider: "opencode-go", icon: "opencode", base_url: "https://opencode.ai/zen/go/v1", admin_url: null, model: "glm-5.2" }, + { kind: "openrouter", name: "OpenRouter", provider: "openrouter", icon: "openrouter", base_url: "https://openrouter.ai/api/v1", admin_url: "https://openrouter.ai/settings/keys", model: "openai/gpt-5.6-sol" }, + { kind: "mimo", name: "小米 MiMo", provider: "mimo", icon: "xiaomi-mimo", base_url: "https://api.xiaomimimo.com/v1", admin_url: "https://platform.xiaomimimo.com/#/console/api-keys", model: "mimo-v2.5-pro" }, + { kind: "custom", name: "自定义", provider: null, icon: "custom", base_url: "", admin_url: null, model: "自定义" }, ]; export function builtinPresetByKind(kind: string): BuiltinPreset | undefined { return builtinPresets.find((preset) => preset.kind === kind); } + +/** 哪些内置供应商自带静态模型目录档。models.json 槽位的存在判定统一走这里。 */ +export const BUILTINS_WITH_CATALOG: ReadonlySet = new Set([ + "deepseek", "minimax", "zhipu", "opencode", "mimo", +]); + +export function builtinHasCatalog(kind: string | null | undefined): boolean { + return kind != null && BUILTINS_WITH_CATALOG.has(kind); +} \ No newline at end of file