Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/2836048681_cc-switch-codexcont.json

1 line
1.7 MiB
Plaintext
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "2836048681/cc-switch-codexcont", "files": {"src-tauri/src/claude_desktop_config.rs": {"1e398a3709f03db3fe5d989827090386b9dcd3be": {"t": 1785595178.1913152, "n": 77564, "h": "fe4a4ef0c5a1af04", "c": "use serde::Serialize;\nuse serde_json::{json, Value};\nuse std::fs;\nuse std::path::{Path, PathBuf};\n\n#[cfg(any(target_os = \"macos\", windows))]\nuse crate::config::get_home_dir;\nuse crate::config::{atomic_write, delete_file, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::database::CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID;\nuse crate::error::AppError;\nuse crate::provider::{ClaudeDesktopMode, Provider};\n\npub const PROFILE_ID: &str = \"00000000-0000-4000-8000-000000157210\";\npub const PROFILE_NAME: &str = \"CC Switch\";\n\n#[cfg(any(target_os = \"macos\", windows, test))]\nconst CONFIG_FILE: &str = \"claude_desktop_config.json\";\n#[cfg(any(target_os = \"macos\", windows, test))]\nconst CONFIG_LIBRARY_DIR: &str = \"configLibrary\";\nconst GATEWAY_TOKEN_SETTING_KEY: &str = \"claude_desktop_gateway_token\";\nconst CLAUDE_DESKTOP_PROXY_PREFIX: &str = \"/claude-desktop\";\nconst DEFAULT_CREATED_AT: &str = \"2024-01-01T00:00:00Z\";\nconst MIMO_REDACTED_THINKING_PLACEHOLDER: &str = \"[redacted thinking]\";\nconst MIMO_TOOL_CALL_THINKING_PLACEHOLDER: &str = \"tool call\";\n\n/// Claude Desktop 模型菜单识别的 route ID 前缀。\npub const CLAUDE_ROUTE_PREFIX: &str = \"claude-\";\n/// 替代前缀(与前端 `ANTHROPIC_CLAUDE_ROUTE_PREFIX` 一致)。\npub const ANTHROPIC_CLAUDE_ROUTE_PREFIX: &str = \"anthropic/claude-\";\n/// Claude Code env 中通过 `[1M]` 后缀声明 1M 上下文能力(匹配用 `eq_ignore_ascii_case`)。\n/// Claude Desktop schema 不接受此后缀,import 边界翻译为 `supports1m` 字段。\npub const ONE_M_CONTEXT_MARKER: &str = \"[1m]\";\n\nconst CURRENT_OPUS_ROUTE_ID: &str = \"claude-opus-4-8\";\nconst LEGACY_OPUS_ROUTE_ID: &str = \"claude-opus-4-7\";\n\n#[derive(Debug, Clone, Copy, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct ClaudeDesktopDefaultRoute {\n pub route_id: &'static str,\n pub env_key: &'static str,\n #[serde(rename = \"supports1m\")]\n pub supports_1m: bool,\n}\n\npub const DEFAULT_PROXY_ROUTES: &[ClaudeDesktopDefaultRoute] = &[\n ClaudeDesktopDefaultRoute {\n route_id: \"claude-sonnet-5\",\n env_key: \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n supports_1m: true,\n },\n ClaudeDesktopDefaultRoute {\n route_id: CURRENT_OPUS_ROUTE_ID,\n env_key: \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n supports_1m: true,\n },\n ClaudeDesktopDefaultRoute {\n route_id: \"claude-haiku-4-5\",\n env_key: \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n supports_1m: true,\n },\n // fable 置于末尾:next_catalog_safe_route_id 给非安全品牌 route 借用合法\n // 角色名时仍按 sonnet→opus→haiku 顺序分配(向后兼容既有 catalog),不会把\n // 无关品牌模型借用成 fable 顶配档名。UI 行序由前端 ROLE_ORDER 独立控制为\n // Sonnet/Opus/Fable/Haiku(所有 proxy 路径都经 normalizeProxyRows 重排),\n // 与此处物理顺序无关。\n ClaudeDesktopDefaultRoute {\n route_id: \"claude-fable-5\",\n env_key: \"ANTHROPIC_DEFAULT_FABLE_MODEL\",\n supports_1m: true,\n },\n];\n\n#[derive(Debug, Clone)]\nstruct ClaudeDesktopPaths {\n normal_config_path: PathBuf,\n threep_config_path: PathBuf,\n config_library_path: PathBuf,\n profile_path: PathBuf,\n meta_path: PathBuf,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct DirectGatewayCredentials {\n pub base_url: String,\n pub api_key: String,\n}\n\n#[derive(Debug, Clone)]\nstruct FileSnapshot {\n path: PathBuf,\n content: Option<Vec<u8>>,\n}\n\n#[derive(Debug, Clone, Serialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct ClaudeDesktopStatus {\n pub supported: bool,\n pub configured: bool,\n pub applied_id: Option<String>,\n pub profile_path: Option<String>,\n pub config_library_path: Option<String>,\n pub mode: Option<ClaudeDesktopMode>,\n pub expected_base_url: Option<String>,\n pub actual_base_url: Option<String>,\n pub proxy_running: bool,\n pub stale_raw_models: bool,\n pub missing_route_mappings: bool,\n pub gateway_token_configured: bool,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct ResolvedModelRoute {\n pub route_id: String,\n pub upstream_model: String,\n pub label_override: Option<String>,\n pub supports_1m: bool,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\nstruct InferenceModelSpec {\n name: String,\n label_override: Option<String>,\n supports_1m: bool,\n}\n\npub fn apply_provider(db: &Database, provider: &Provider) -> Result<(), AppError> {\n let paths = current_platform_paths()?;\n apply_provider_to_paths(db, provider, &paths)\n}\n\npub fn get_status(db: &Database, proxy_running: bool) -> Result<ClaudeDesktopStatus, AppError> {\n if !is_supported_platform() {\n return Ok(ClaudeDesktopStatus {\n supported: false,\n configured: false,\n applied_id: None,\n profile_path: None,\n config_library_path: None,\n mode: None,\n expected_base_url: None,\n actual_base_url: None,\n proxy_running,\n stale_raw_models: false,\n missing_route_mappings: false,\n gateway_token_configured: false,\n });\n }\n\n let paths = current_platform_paths()?;\n let applied_id = read_applied_id(&paths.meta_path);\n let configured = paths.profile_path.exists() || meta_has_profile_entry(&paths.meta_path);\n let profile = read_json_or_empty(&paths.profile_path).unwrap_or_else(|_| json!({}));\n let actual_base_url = profile\n .get(\"inferenceGatewayBaseUrl\")\n .and_then(Value::as_str)\n .map(str::to_string);\n let stale_raw_models = profile\n .get(\"inferenceModels\")\n .and_then(Value::as_array)\n .map(|models| {\n models.iter().any(|item| {\n item.as_str()\n .or_else(|| item.get(\"name\").and_then(Value::as_str))\n .is_some_and(|model| !is_claude_safe_model_id(model))\n })\n })\n .unwrap_or(false);\n let gateway_token_configured = db\n .get_setting(GATEWAY_TOKEN_SETTING_KEY)\n .ok()\n .flatten()\n .is_some_and(|token| !token.trim().is_empty());\n let current_provider = crate::settings::get_effective_current_provider(\n db,\n &crate::app_config::AppType::ClaudeDesktop,\n )\n .ok()\n .flatten()\n .and_then(|id| db.get_provider_by_id(&id, \"claude-desktop\").ok().flatten());\n let mode = current_provider.as_ref().map(provider_mode);\n let expected_base_url = match mode {\n Some(ClaudeDesktopMode::Proxy) => proxy_gateway_base_url_from_db(db).ok(),\n Some(ClaudeDesktopMode::Direct) => current_provider\n .as_ref()\n .and_then(|provider| direct_gateway_credentials(provider).ok())\n .map(|credentials| credentials.base_url),\n None => None,\n };\n let missing_route_mappings = current_provider.as_ref().is_some_and(|provider| {\n matches!(provider_mode(provider), ClaudeDesktopMode::Proxy)\n && proxy_model_routes(provider).is_err()\n });\n\n Ok(ClaudeDesktopStatus {\n supported: true,\n configured,\n applied_id,\n profile_path: Some(paths.profile_path.display().to_string()),\n config_library_path: Some(paths.config_library_path.display().to_string()),\n mode,\n expected_base_url,\n actual_base_url,\n proxy_running,\n stale_raw_models,\n missing_route_mappings,\n gateway_token_configured,\n })\n}\n\npub fn get_config_library_path() -> Result<PathBuf, AppError> {\n Ok(current_platform_paths()?.config_library_path)\n}\n\npub fn default_proxy_routes() -> Vec<ClaudeDesktopDefaultRoute> {\n DEFAULT_PROXY_ROUTES.to_vec()\n}\n\npub fn is_compatible_direct_provider(provider: &Provider) -> bool {\n validate_direct_provider(provider).is_ok()\n}\n\npub fn is_official_provider(provider: &Provider) -> bool {\n provider.id == CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID\n}\n\npub fn provider_mode(provider: &Provider) -> ClaudeDesktopMode {\n provider\n .meta\n .as_ref()\n .and_then(|meta| meta.claude_desktop_mode.clone())\n .unwrap_or(ClaudeDesktopMode::Direct)\n}\n\npub fn is_claude_safe_model_id(model: &str) -> bool {\n let normalized = model.trim().to_ascii_lowercase();\n if normalized.contains(ONE_M_CONTEXT_MARKER) {\n return false;\n }\n\n let Some(route_tail) = normalized\n .strip_prefix(ANTHROPIC_CLAUDE_ROUTE_PREFIX)\n .or_else(|| normalized.strip_prefix(CLAUDE_ROUTE_PREFIX))\n else {\n return false;\n };\n\n // 角色前缀后必须还有实际模型标识,拒绝 claude-sonnet- 这类退化值\n // (否则会写入 profile 并触发 Claude Desktop fail-all 拒收整组)。\n // Claude Desktop 1.12603.1+ 的 fail-all validator 角色白名单已纳入 fable\n // (app.asar 内 [\"sonnet\",\"opus\",\"haiku\",\"fable\",\"mythos\"]),故 claude-fable-*\n // 可安全写入 profile。mythos 官方未公开发布,暂不暴露给用户。\n [\"sonnet-\", \"opus-\", \"haiku-\", \"fable-\"]\n .iter()\n .any(|prefix| {\n route_tail\n .strip_prefix(prefix)\n .is_some_and(|rest| !rest.is_empty())\n })\n}\n\nfn inference_model_json(spec: &InferenceModelSpec) -> Value {\n if spec.supports_1m || spec.label_override.is_some() {\n let mut item = json!({ \"name\": spec.name });\n if let Some(label_override) = spec.label_override.as_deref() {\n item[\"labelOverride\"] = json!(label_override);\n }\n if spec.supports_1m {\n item[\"supports1m\"] = json!(true);\n }\n item\n } else {\n Value::String(spec.name.clone())\n }\n}\n\npub fn get_or_create_gateway_token(db: &Database) -> Result<String, AppError> {\n if let Some(token) = db.get_setting(GATEWAY_TOKEN_SETTING_KEY)? {\n let trimmed = token.trim();\n if !trimmed.is_empty() {\n return Ok(trimmed.to_string());\n }\n }\n\n let token = format!(\"ccs-{}\", uuid::Uuid::new_v4().simple());\n db.set_setting(GATEWAY_TOKEN_SETTING_KEY, &token)?;\n Ok(token)\n}\n\npub fn direct_gateway_credentials(\n provider: &Provider,\n) -> Result<DirectGatewayCredentials, AppError> {\n let env = provider\n .settings_config\n .get(\"env\")\n .and_then(Value::as_object)\n .ok_or_else(|| {\n AppError::localized(\n \"claude_desktop.provider.env_missing\",\n \"Claude Desktop 直连供应商缺少 env 配置\",\n \"Claude Desktop direct provider is missing env configuration\",\n )\n })?;\n\n let base_url = env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .ok_or_else(|| {\n AppError::localized(\n \"claude_desktop.provider.base_url_missing\",\n \"Claude Desktop 直连供应商缺少 ANTHROPIC_BASE_URL\",\n \"Claude Desktop direct provider is missing ANTHROPIC_BASE_URL\",\n )\n })?\n .to_string();\n\n let api_key = env\n .get(\"ANTHROPIC_AUTH_TOKEN\")\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .ok_or_else(|| {\n AppError::localized(\n \"claude_desktop.provider.auth_token_missing\",\n \"Claude Desktop 直连供应商缺少 ANTHROPIC_AUTH_TOKEN(Bearer Token)\",\n \"Claude Desktop direct provider is missing ANTHROPIC_AUTH_TOKEN (Bearer Token)\",\n )\n })?\n .to_string();\n\n Ok(DirectGatewayCredentials { base_url, api_key })\n}\n\npub fn validate_direct_provider(provider: &Provider) -> Result<(), AppError> {\n if is_official_provider(provider) {\n return Ok(());\n }\n\n if !provider.settings_config.is_object() {\n return Err(AppError::localized(\n \"claude_desktop.provider.settings_not_object\",\n \"Claude Desktop 直连供应商配置必须是 JSON 对象\",\n \"Claude Desktop direct provider configuration must be a JSON object\",\n ));\n }\n\n if let Some(meta) = provider.meta.as_ref() {\n if let Some(api_format) = meta.api_format.as_deref() {\n if !api_format.trim().is_empty() && api_format != \"anthropic\" {\n return Err(AppError::localized(\n \"claude_desktop.provider.api_format_unsupported\",\n \"Claude Desktop 第一阶段只支持原生 Anthropic Messages API\",\n \"Claude Desktop phase 1 only supports native Anthropic Messages API\",\n ));\n }\n }\n\n if matches!(\n meta.claude_desktop_mode.as_ref(),\n Some(ClaudeDesktopMode::Proxy)\n ) {\n return Err(AppError::localized(\n \"claude_desktop.provider.mode_unsupported\",\n \"该供应商是 Claude Desktop 本地路由模式,不能按直连模式写入\",\n \"This Claude Desktop provider uses proxy mode and cannot be written as direct mode\",\n ));\n }\n\n if matches!(\n meta.provider_type.as_deref(),\n Some(\"github_copilot\") | Some(\"codex_oauth\")\n ) {\n return Err(AppError::localized(\n \"claude_desktop.provider.type_unsupported\",\n \"Claude Desktop 直连模式不支持需要本地代理转换的供应商\",\n \"Claude Desktop direct mode does not support providers that require local proxy conversion\",\n ));\n }\n\n if meta.is_full_url == Some(true) {\n return Err(AppError::localized(\n \"claude_desktop.provider.full_url_unsupported\",\n \"Claude Desktop 直连模式不支持完整 URL 端点配置\",\n \"Claude Desktop direct mode does not support full URL endpoint configuration\",\n ));\n }\n }\n\n direct_inference_model_specs(provider)?;\n direct_gateway_credentials(provider)?;\n Ok(())\n}\n\npub fn validate_proxy_provider(provider: &Provider) -> Result<(), AppError> {\n if is_official_provider(provider) {\n return Ok(());\n }\n\n if !provider.settings_config.is_object() {\n return Err(AppError::localized(\n \"claude_desktop.provider.settings_not_object\",\n \"Claude Desktop 本地路由供应商配置必须是 JSON 对象\",\n \"Claude Desktop proxy provider configuration must be a JSON object\",\n ));\n }\n\n if let Some(meta) = provider.meta.as_ref() {\n if let Some(api_format) = meta.api_format.as_deref() {\n if !matches!(\n api_format,\n \"\" | \"anthropic\" | \"openai_chat\" | \"openai_responses\" | \"gemini_native\"\n ) {\n return Err(AppError::localized(\n \"claude_desktop.provider.api_format_unsupported\",\n format!(\"Claude Desktop 本地路由模式不支持 API 格式: {api_format}\"),\n format!(\"Claude Desktop proxy mode does not support API format: {api_format}\"),\n ));\n }\n }\n }\n\n proxy_model_routes(provider)?;\n\n if !has_proxy_base_url_and_key(provider) {\n return Err(AppError::localized(\n \"claude_desktop.provider.credentials_missing\",\n \"Claude Desktop 本地路由供应商缺少 Base URL 或 API Key\",\n \"Claude Desktop proxy provider is missing Base URL or API key\",\n ));\n }\n\n Ok(())\n}\n\nfn has_proxy_base_url_and_key(provider: &Provider) -> bool {\n let env = provider.settings_config.get(\"env\");\n let has_base_url = env\n .and_then(|value| value.get(\"ANTHROPIC_BASE_URL\"))\n .or_else(|| provider.settings_config.get(\"base_url\"))\n .or_else(|| provider.settings_config.get(\"baseURL\"))\n .or_else(|| provider.settings_config.get(\"apiEndpoint\"))\n .and_then(Value::as_str)\n .map(str::trim)\n .is_some_and(|value| !value.is_empty());\n\n if is_managed_oauth_proxy_provider(provider) {\n return has_base_url;\n }\n\n let has_key = env\n .and_then(|value| {\n [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| value.get(key))\n })\n .or_else(|| provider.settings_config.get(\"apiKey\"))\n .or_else(|| provider.settings_config.get(\"api_key\"))\n .and_then(Value::as_str)\n .map(str::trim)\n .is_some_and(|value| !value.is_empty());\n\n has_base_url && has_key\n}\n\nfn is_managed_oauth_proxy_provider(provider: &Provider) -> bool {\n provider\n .meta\n .as_ref()\n .and_then(|meta| meta.provider_type.as_deref())\n .is_some_and(|provider_type| matches!(provider_type, \"github_copilot\" | \"codex_oauth\"))\n}\n\npub fn validate_provider(provider: &Provider) -> Result<(), AppError> {\n if is_official_provider(provider) {\n return Ok(());\n }\n\n match provider_mode(provider) {\n ClaudeDesktopMode::Direct => validate_direct_provider(provider),\n ClaudeDesktopMode::Proxy => validate_proxy_provider(provider),\n }\n}\n\nfn direct_inference_model_specs(provider: &Provider) -> Result<Vec<InferenceModelSpec>, AppError> {\n let Some(routes) = provider\n .meta\n .as_ref()\n .map(|meta| &meta.claude_desktop_model_routes)\n else {\n return Ok(Vec::new());\n };\n\n let mut result = Vec::new();\n for (route_id, route) in routes {\n let supports_1m = route.supports_1m.unwrap_or(false);\n let route_id = route_id.trim();\n if route_id.is_empty() {\n continue;\n }\n if !is_claude_safe_model_id(route_id) {\n return Err(AppError::localized(\n \"claude_desktop.provider.route_invalid\",\n format!(\n \"Claude Desktop 直连模型必须使用 claude-* 或 anthropic/claude-* 名称: {route_id}\"\n ),\n format!(\n \"Claude Desktop direct model must use a claude-* or anthropic/claude-* name: {route_id}\"\n ),\n ));\n }\n let upstream_model = route.model.trim();\n if !upstream_model.is_empty() && upstream_model != route_id {\n return Err(AppError::localized(\n \"claude_desktop.provider.direct_mapping_unsupported\",\n format!(\n \"Claude Desktop 直连模式不能映射模型: {route_id} -> {upstream_model};非 Claude 官方模型请使用本地路由模式\"\n ),\n format!(\n \"Claude Desktop direct mode cannot map models: {route_id} -> {upstream_model}; use proxy mode for non-Claude official models\"\n ),\n ));\n }\n result.push(InferenceModelSpec {\n name: route_id.to_string(),\n label_override: route\n .label_override\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .map(str::to_string),\n supports_1m,\n });\n }\n\n // Sort supports_1m=true first within each name so the subsequent dedup_by\n // (which keeps the first occurrence) preserves the 1M-capable variant.\n result.sort_by(|a, b| {\n a.name\n .cmp(&b.name)\n .then_with(|| b.supports_1m.cmp(&a.supports_1m))\n });\n result.dedup_by(|a, b| a.name == b.name);\n Ok(result)\n}\n\npub fn proxy_model_routes(provider: &Provider) -> Result<Vec<ResolvedModelRoute>, AppError> {\n let routes = provider\n .meta\n .as_ref()\n .map(|meta| &meta.claude_desktop_model_routes)\n .ok_or_else(|| {\n AppError::localized(\n \"claude_desktop.provider.routes_missing\",\n \"Claude Desktop 本地路由模式缺少模型路由映射\",\n \"Claude Desktop proxy mode is missing model route mappings\",\n )\n })?;\n\n let reserved_route_ids = routes\n .keys()\n .map(|route_id| route_id.trim())\n .filter(|route_id| is_claude_safe_model_id(route_id))\n .map(str::to_string)\n .collect::<std::collections::HashSet<_>>();\n let mut result = Vec::new();\n let mut entries = routes.iter().collect::<Vec<_>>();\n entries.sort_by_key(|(left, _)| *left);\n for (route_id, route) in entries {\n let supports_1m = route.supports_1m.unwrap_or(false);\n let route_id = route_id.trim();\n let upstream_model = route.model.trim();\n if route_id.is_empty() || upstream_model.is_empty() {\n continue;\n }\n let repaired_route_id = if is_claude_safe_model_id(route_id) {\n route_id.to_string()\n } else {\n next_catalog_safe_route_id(&result, &reserved_route_ids)\n };\n result.push(ResolvedModelRoute {\n route_id: repaired_route_id,\n upstream_model: upstream_model.to_string(),\n label_override: route\n .label_override\n .as_deref()\n .map(str::trim)\n .filter(|value| !value.is_empty())\n .map(str::to_string)\n .or_else(|| {\n (!is_claude_safe_model_id(route_id)).then(|| upstream_model.to_string())\n }),\n supports_1m,\n });\n }\n\n result.sort_by(|a, b| a.route_id.cmp(&b.route_id));\n result.dedup_by(|a, b| a.route_id == b.route_id);\n\n if result.is_empty() {\n return Err(AppError::localized(\n \"claude_desktop.provider.routes_missing\",\n \"Claude Desktop 本地路由模式至少需要一个模型路由映射\",\n \"Claude Desktop proxy mode requires at least one model route mapping\",\n ));\n }\n\n Ok(result)\n}\n\nfn next_catalog_safe_route_id(\n existing: &[ResolvedModelRoute],\n reserved: &std::collections::HashSet<String>,\n) -> String {\n if let Some(default_route) = DEFAULT_PROXY_ROUTES\n .iter()\n .map(|route| route.route_id)\n .find(|route_id| {\n !reserved.contains(*route_id)\n && !existing.iter().any(|route| route.route_id == *route_id)\n })\n {\n return default_route.to_string();\n }\n\n let mut index = 2usize;\n loop {\n let route_id = format!(\"{}-r{index}\", DEFAULT_PROXY_ROUTES[0].route_id);\n if !reserved.contains(&route_id) && !existing.iter().any(|route| route.route_id == route_id)\n {\n return route_id;\n }\n index += 1;\n }\n}\n\npub fn model_list_response(provider: &Provider) -> Result<Value, AppError> {\n let routes = proxy_model_routes(provider)?;\n let data: Vec<Value> = routes\n .iter()\n .map(|route| {\n let model_id = route.route_id.clone();\n let mut item = json!({\n \"type\": \"model\",\n \"id\": model_id,\n \"created_at\": DEFAULT_CREATED_AT,\n });\n if route.supports_1m {\n item[\"supports1m\"] = json!(true);\n }\n item\n })\n .collect();\n let first_id = data\n .first()\n .and_then(|item| item.get(\"id\"))\n .and_then(Value::as_str)\n .map(str::to_string);\n let last_id = data\n .last()\n .and_then(|item| item.get(\"id\"))\n .and_then(Value::as_str)\n .map(str::to_string);\n\n Ok(json!({\n \"data\": data,\n \"has_more\": false,\n \"first_id\": first_id,\n \"last_id\": last_id,\n }))\n}\n\npub fn map_proxy_request_model(mut body: Value, provider: &Provider) -> Result<Value, AppError> {\n let requested_raw = body\n .get(\"model\")\n .and_then(Value::as_str)\n .map(str::trim)\n .map(str::to_string)\n .filter(|value| !value.is_empty())\n .ok_or_else(|| {\n AppError::localized(\n \"claude_desktop.provider.model_missing\",\n \"Claude Desktop 请求缺少 model 字段\",\n \"Claude Desktop request is missing the model field\",\n )\n })?;\n let requested = strip_one_m_suffix_for_route_lookup(&requested_raw);\n\n let routes = proxy_model_routes(provider)?;\n let upstream_model = routes\n .iter()\n .find(|r| r.route_id == requested)\n .or_else(|| {\n routes\n .iter()\n .find(|r| is_compatible_opus_route_alias(&r.route_id, requested))\n })\n .map(|route| route.upstream_model.clone())\n .or_else(|| legacy_raw_route_upstream_model(provider, requested))\n .or_else(|| {\n // 角色关键词回落:Claude Desktop 的部分调用(如子 agent)会请求带发布\n // 日期后缀的完整官方名(claude-haiku-4-5-20251001),与 manifest 暴露的\n // 简短 route_id(claude-haiku-4-5)不精确相等。按 opus/haiku/fable/sonnet\n // 归类到同档已配置路由,对齐 Claude Code model_mapper 的宽松匹配。\n // 匹配前已剥离本地 [1m] 标记;这里仍只对 Claude Desktop 认可的\n // 安全模型名回落,避免非 Claude route 被误映射。\n if !is_claude_safe_model_id(requested) {\n return None;\n }\n let role = claude_role_keyword(requested)?;\n routes\n .iter()\n .find(|route| claude_role_keyword(&route.route_id) == Some(role))\n // 老用户只配了 Sonnet/Opus/Haiku 三档时,fable 请求降级到 opus 档,\n // 与官方安全分类器的降级方向一致,避免 route_unknown 硬错误。\n // 用户一旦显式配置 fable 档,上面的精确角色匹配会优先命中。\n .or_else(|| {\n (role == \"fable\")\n .then(|| {\n routes\n .iter()\n .find(|route| claude_role_keyword(&route.route_id) == Some(\"opus\"))\n })\n .flatten()\n })\n .map(|route| route.upstream_model.clone())\n })\n .ok_or_else(|| {\n AppError::localized(\n \"claude_desktop.provider.route_unknown\",\n format!(\"Claude Desktop 模型路由未配置: {requested_raw}\"),\n format!(\"Claude Desktop model route is not configured: {requested_raw}\"),\n )\n })?;\n\n body[\"model\"] = json!(upstream_model);\n if should_normalize_mimo_anthropic_thinking_history(provider, &upstream_model) {\n normalize_mimo_anthropic_thinking_history(&mut body);\n }\n Ok(body)\n}\n\nfn strip_one_m_suffix_for_route_lookup(model: &str) -> &str {\n let trimmed = model.trim();\n let marker = ONE_M_CONTEXT_MARKER.as_bytes();\n let bytes = trimmed.as_bytes();\n if bytes.len() >= marker.len()\n && bytes[bytes.len() - marker.len()..].eq_ignore_ascii_case(marker)\n {\n return trimmed[..trimmed.len() - marker.len()].trim_end();\n }\n trimmed\n}\n\nfn legacy_raw_route_upstream_model(provider: &Provider, requested: &str) -> Option<String> {\n provider\n .meta\n .as_ref()?\n .claude_desktop_model_routes\n .iter()\n .find(|(route_id, _)| route_id.trim() == requested)\n .and_then(|(_, route)| {\n let upstream_model = route.model.trim();\n (!upstream_model.is_empty()).then(|| upstream_model.to_string())\n })\n}\n\nfn is_compatible_opus_route_alias(route_id: &str, requested: &str) -> bool {\n matches!(\n (route_id, requested),\n (CURRENT_OPUS_ROUTE_ID, LEGACY_OPUS_ROUTE_ID)\n | (LEGACY_OPUS_ROUTE_ID, CURRENT_OPUS_ROUTE_ID)\n )\n}\n\n/// 按角色关键词(opus / haiku / fable / sonnet)归类一个 Claude 模型名/route_id。\n/// 仅在命中明确角色词时返回 Some,未知模型返回 None(不回落,保持精确报错语义)。\n/// 与前端 `routeRoleFromId` 同序(opus → haiku → fable → sonnet)。\nfn claude_role_keyword(model: &str) -> Option<&'static str> {\n let normalized = model.to_ascii_lowercase();\n if normalized.contains(\"opus\") {\n Some(\"opus\")\n } else if normalized.contains(\"haiku\") {\n Some(\"haiku\")\n } else if normalized.contains(\"fable\") {\n Some(\"fable\")\n } else if normalized.contains(\"sonnet\") {\n Some(\"sonnet\")\n } else {\n None\n }\n}\n\nfn should_normalize_mimo_anthropic_thinking_history(\n provider: &Provider,\n upstream_model: &str,\n) -> bool {\n if !provider_uses_anthropic_messages_format(provider) {\n return false;\n }\n\n is_mimo_identifier(upstream_model) || provider_has_mimo_endpoint(provider)\n}\n\nfn provider_uses_anthropic_messages_format(provider: &Provider) -> bool {\n let api_format = provider\n .meta\n .as_ref()\n .and_then(|meta| meta.api_format.as_deref())\n .or_else(|| {\n provider\n .settings_config\n .get(\"api_format\")\n .and_then(Value::as_str)\n })\n .map(str::trim)\n .unwrap_or(\"anthropic\");\n\n api_format.is_empty() || api_format == \"anthropic\"\n}\n\nfn provider_has_mimo_endpoint(provider: &Provider) -> bool {\n let settings = &provider.settings_config;\n [\n settings\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_BASE_URL\"))\n .and_then(Value::as_str),\n settings.get(\"base_url\").and_then(Value::as_str),\n settings.get(\"baseURL\").and_then(Value::as_str),\n settings.get(\"apiEndpoint\").and_then(Value::as_str),\n ]\n .into_iter()\n .flatten()\n .any(is_mimo_identifier)\n}\n\nfn is_mimo_identifier(value: &str) -> bool {\n let value = value.to_ascii_lowercase();\n value.contains(\"mimo\") || value.contains(\"xiaomimimo\")\n}\n\nfn normalize_mimo_anthropic_thinking_history(body: &mut Value) {\n let Some(messages) = body.get_mut(\"messages\").and_then(Value::as_array_mut) else {\n return;\n };\n\n for message in messages {\n if message.get(\"role\").and_then(Value::as_str) != Some(\"assistant\") {\n continue;\n }\n\n let Some(content) = message.get_mut(\"content\").and_then(Value::as_array_mut) else {\n continue;\n };\n if !content\n .iter()\n .any(|block| block.get(\"type\").and_then(Value::as_str) == Some(\"tool_use\"))\n {\n continue;\n }\n\n let mut has_thinking = false;\n for block in content.iter_mut() {\n match block.get(\"type\").and_then(Value::as_str) {\n Some(\"thinking\") => {\n let has_non_empty_thinking = block\n .get(\"thinking\")\n .and_then(Value::as_str)\n .is_some_and(|value| !value.trim().is_empty());\n if let Some(obj) = block.as_object_mut() {\n obj.remove(\"signature\");\n }\n if has_non_empty_thinking {\n has_thinking = true;\n } else if let Some(obj) = block.as_object_mut() {\n obj.insert(\n \"thinking\".to_string(),\n json!(MIMO_TOOL_CALL_THINKING_PLACEHOLDER),\n );\n has_thinking = true;\n }\n }\n Some(\"redacted_thinking\") => {\n *block = json!({\n \"type\": \"thinking\",\n \"thinking\": MIMO_REDACTED_THINKING_PLACEHOLDER\n });\n has_thinking = true;\n }\n _ => {}\n }\n }\n\n if !has_thinking {\n content.insert(\n 0,\n json!({\n \"type\": \"thinking\",\n \"thinking\": MIMO_TOOL_CALL_THINKING_PLACEHOLDER\n }),\n );\n }\n }\n}\n\npub fn proxy_gateway_base_url_from_db(db: &Database) -> Result<String, AppError> {\n // get_proxy_config is async-tagged but its body is fully synchronous (rusqlite\n // under a Mutex), so block_on cannot deadlock the calling thread.\n let config = futures::executor::block_on(db.get_proxy_config())?;\n if config.listen_port == 0 {\n return Err(AppError::Config(\n \"Claude Desktop 代理地址需要真实监听端口;请先启动本地代理或使用固定端口\".to_string(),\n ));\n }\n Ok(format!(\n \"{}{}\",\n proxy_origin_from_parts(&config.listen_address, config.listen_port),\n CLAUDE_DESKTOP_PROXY_PREFIX\n ))\n}\n\nfn apply_provider_to_paths(\n db: &Database,\n provider: &Provider,\n paths: &ClaudeDesktopPaths,\n) -> Result<(), AppError> {\n if is_official_provider(provider) {\n return restore_official_at_paths(paths);\n }\n\n validate_provider(provider)?;\n with_rollback(paths, |paths| {\n apply_provider_to_paths_inner(db, provider, paths)\n })\n}\n\nfn restore_official_at_paths(paths: &ClaudeDesktopPaths) -> Result<(), AppError> {\n with_rollback(paths, restore_official_at_paths_inner)\n}\n\nfn with_rollback<F>(paths: &ClaudeDesktopPaths, op: F) -> Result<(), AppError>\nwhere\n F: FnOnce(&ClaudeDesktopPaths) -> Result<(), AppError>,\n{\n let snapshots = snapshot_files(paths)?;\n match op(paths) {\n Ok(()) => Ok(()),\n Err(err) => match restore_snapshots(&snapshots) {\n Ok(()) => Err(err),\n Err(rollback_err) => {\n log::error!(\"Failed to rollback Claude Desktop config after error: {rollback_err}\");\n Err(AppError::Message(format!(\n \"{err}; rollback failed: {rollback_err}\"\n )))\n }\n },\n }\n}\n\nfn apply_provider_to_paths_inner(\n db: &Database,\n provider: &Provider,\n paths: &ClaudeDesktopPaths,\n) -> Result<(), AppError> {\n let profile = match provider_mode(provider) {\n ClaudeDesktopMode::Direct => {\n let credentials = direct_gateway_credentials(provider)?;\n let model_specs = direct_inference_model_specs(provider)?;\n build_gateway_profile(\n &credentials.base_url,\n &credentials.api_key,\n (!model_specs.is_empty()).then_some(model_specs.as_slice()),\n )\n }\n ClaudeDesktopMode::Proxy => {\n let base_url = proxy_gateway_base_url_from_db(db)?;\n let api_key = get_or_create_gateway_token(db)?;\n let routes = proxy_model_routes(provider)?;\n let model_specs = routes\n .iter()\n .map(|route| InferenceModelSpec {\n name: route.route_id.clone(),\n label_override: route.label_override.clone(),\n supports_1m: route.supports_1m,\n })\n .collect::<Vec<_>>();\n build_gateway_profile(&base_url, &api_key, Some(model_specs.as_slice()))\n }\n };\n\n write_deployment_mode(&paths.normal_config_path, \"3p\")?;\n write_deployment_mode(&paths.threep_config_path, \"3p\")?;\n write_json_file(&paths.profile_path, &profile)?;\n write_meta(&paths.meta_path, Some(PROFILE_ID))?;\n\n Ok(())\n}\n\nfn restore_official_at_paths_inner(paths: &ClaudeDesktopPaths) -> Result<(), AppError> {\n write_deployment_mode(&paths.normal_config_path, \"1p\")?;\n write_deployment_mode(&paths.threep_config_path, \"1p\")?;\n remove_cc_switch_enterprise_config(&paths.threep_config_path)?;\n\n if paths.profile_path.exists() {\n delete_file(&paths.profile_path)?;\n }\n write_meta(&paths.meta_path, None)?;\n\n Ok(())\n}\n\nfn build_gateway_profile(\n base_url: &str,\n api_key: &str,\n model_specs: Option<&[InferenceModelSpec]>,\n) -> Value {\n let mut profile = json!({\n \"coworkEgressAllowedHosts\": [\"*\"],\n \"disableDeploymentModeChooser\": true,\n \"inferenceGatewayApiKey\": api_key,\n \"inferenceGatewayAuthScheme\": \"bearer\",\n \"inferenceGatewayBaseUrl\": base_url,\n \"inferenceProvider\": \"gateway\"\n });\n\n if let Some(model_specs) = model_specs {\n profile[\"inferenceModels\"] =\n Value::Array(model_specs.iter().map(inference_model_json).collect());\n }\n\n profile\n}\n\nfn read_json_or_empty(path: &Path) -> Result<Value, AppError> {\n let value = if path.exists() {\n read_json_file(path)?\n } else {\n json!({})\n };\n\n if value.is_object() {\n Ok(value)\n } else {\n Ok(json!({}))\n }\n}\n\nfn snapshot_files(paths: &ClaudeDesktopPaths) -> Result<Vec<FileSnapshot>, AppError> {\n [\n &paths.normal_config_path,\n &paths.threep_config_path,\n &paths.profile_path,\n &paths.meta_path,\n ]\n .into_iter()\n .map(|path| {\n let content = if path.exists() {\n Some(fs::read(path).map_err(|e| AppError::io(path, e))?)\n } else {\n None\n };\n Ok(FileSnapshot {\n path: path.clone(),\n content,\n })\n })\n .collect()\n}\n\nfn restore_snapshots(snapshots: &[FileSnapshot]) -> Result<(), AppError> {\n for snapshot in snapshots {\n match &snapshot.content {\n Some(content) => {\n if let Some(parent) = snapshot.path.parent() {\n fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n }\n atomic_write(&snapshot.path, content)?;\n }\n None => {\n delete_file(&snapshot.path)?;\n }\n }\n }\n Ok(())\n}\n\nfn write_deployment_mode(path: &Path, mode: &str) -> Result<(), AppError> {\n let mut value = read_json_or_empty(path)?;\n if !value.is_object() {\n value = json!({});\n }\n if let Some(obj) = value.as_object_mut() {\n obj.insert(\n \"deploymentMode\".to_string(),\n Value::String(mode.to_string()),\n );\n }\n write_json_file(path, &value)\n}\n\nfn remove_cc_switch_enterprise_config(path: &Path) -> Result<(), AppError> {\n if !path.exists() {\n return Ok(());\n }\n\n let mut value = read_json_or_empty(path)?;\n let Some(obj) = value.as_object_mut() else {\n return Ok(());\n };\n let Some(enterprise) = obj\n .get_mut(\"enterpriseConfig\")\n .and_then(Value::as_object_mut)\n else {\n return Ok(());\n };\n\n for key in [\n \"disableDeploymentModeChooser\",\n \"inferenceGatewayApiKey\",\n \"inferenceGatewayAuthScheme\",\n \"inferenceGatewayBaseUrl\",\n \"inferenceProvider\",\n ] {\n enterprise.remove(key);\n }\n\n if enterprise.is_empty() {\n obj.remove(\"enterpriseConfig\");\n }\n\n write_json_file(path, &value)\n}\n\nfn write_meta(path: &Path, applied_profile_id: Option<&str>) -> Result<(), AppError> {\n let mut value = read_json_or_empty(path)?;\n if !value.is_object() {\n value = json!({});\n }\n\n let obj = value.as_object_mut().expect(\"just normalized to object\");\n let mut entries = obj\n .get(\"entries\")\n .and_then(Value::as_array)\n .cloned()\n .unwrap_or_default();\n\n entries.retain(|entry| entry.get(\"id\").and_then(Value::as_str) != Some(PROFILE_ID));\n\n match applied_profile_id {\n Some(id) => {\n entries.push(json!({\n \"id\": PROFILE_ID,\n \"name\": PROFILE_NAME\n }));\n obj.insert(\"appliedId\".to_string(), Value::String(id.to_string()));\n }\n None => {\n let should_clear_applied = obj\n .get(\"appliedId\")\n .and_then(Value::as_str)\n .is_some_and(|id| id == PROFILE_ID);\n if should_clear_applied {\n if let Some(next_id) = entries\n .iter()\n .find_map(|entry| entry.get(\"id\").and_then(Value::as_str))\n {\n obj.insert(\"appliedId\".to_string(), Value::String(next_id.to_string()));\n } else {\n obj.remove(\"appliedId\");\n }\n }\n }\n }\n\n obj.insert(\"entries\".to_string(), Value::Array(entries));\n write_json_file(path, &value)\n}\n\nfn read_applied_id(path: &Path) -> Option<String> {\n read_json_or_empty(path).ok().and_then(|value| {\n value\n .get(\"appliedId\")\n .and_then(Value::as_str)\n .map(str::to_string)\n })\n}\n\nfn meta_has_profile_entry(path: &Path) -> bool {\n read_json_or_empty(path)\n .ok()\n .and_then(|value| value.get(\"entries\").and_then(Value::as_array).cloned())\n .is_some_and(|entries| {\n entries\n .iter()\n .any(|entry| entry.get(\"id\").and_then(Value::as_str) == Some(PROFILE_ID))\n })\n}\n\nfn is_supported_platform() -> bool {\n cfg!(any(target_os = \"macos\", windows))\n}\n\n#[allow(clippy::needless_return)]\nfn current_platform_paths() -> Result<ClaudeDesktopPaths, AppError> {\n #[cfg(target_os = \"macos\")]\n {\n return Ok(macos_paths_from_home(&get_home_dir()));\n }\n\n #[cfg(windows)]\n {\n let local_app_data = windows_local_app_data_dir();\n return Ok(windows_paths_from_local_app_data(&local_app_data));\n }\n\n #[cfg(not(any(target_os = \"macos\", windows)))]\n {\n Err(unsupported_platform_error())\n }\n}\n\n#[cfg(target_os = \"macos\")]\nfn macos_paths_from_home(home: &Path) -> ClaudeDesktopPaths {\n let app_support = home.join(\"Library\").join(\"Application Support\");\n paths_from_dirs(app_support.join(\"Claude\"), app_support.join(\"Claude-3p\"))\n}\n\n#[cfg(windows)]\nfn windows_local_app_data_dir() -> PathBuf {\n std::env::var_os(\"LOCALAPPDATA\")\n .map(PathBuf::from)\n .unwrap_or_else(|| get_home_dir().join(\"AppData\").join(\"Local\"))\n}\n\n#[cfg(windows)]\nfn windows_paths_from_local_app_data(local_app_data: &Path) -> ClaudeDesktopPaths {\n let normal_dir = pick_windows_claude_dir(local_app_data, false)\n .unwrap_or_else(|| local_app_data.join(\"Claude\"));\n let threep_dir = pick_windows_claude_dir(local_app_data, true)\n .unwrap_or_else(|| local_app_data.join(\"Claude-3p\"));\n paths_from_dirs(normal_dir, threep_dir)\n}\n\n#[cfg(windows)]\nfn pick_windows_claude_dir(local_app_data: &Path, threep: bool) -> Option<PathBuf> {\n let exact_name = if threep { \"Claude-3p\" } else { \"Claude\" };\n let exact = local_app_data.join(exact_name);\n if exact.exists() {\n return Some(exact);\n }\n\n let mut candidates: Vec<PathBuf> = std::fs::read_dir(local_app_data)\n .ok()?\n .filter_map(Result::ok)\n .map(|entry| entry.path())\n .filter(|path| path.is_dir())\n .filter(|path| {\n let Some(name) = path.file_name().and_then(|value| value.to_str()) else {\n return false;\n };\n let starts = name.starts_with(\"Claude\");\n let is_threep = name.contains(\"-3p\");\n starts && is_threep == threep\n })\n .collect();\n candidates.sort();\n candidates.into_iter().next()\n}\n\n#[cfg(any(target_os = \"macos\", windows, test))]\nfn paths_from_dirs(normal_dir: PathBuf, threep_dir: PathBuf) -> ClaudeDesktopPaths {\n let config_library_path = threep_dir.join(CONFIG_LIBRARY_DIR);\n let profile_path = config_library_path.join(format!(\"{PROFILE_ID}.json\"));\n let meta_path = config_library_path.join(\"_meta.json\");\n\n ClaudeDesktopPaths {\n normal_config_path: normal_dir.join(CONFIG_FILE),\n threep_config_path: threep_dir.join(CONFIG_FILE),\n config_library_path,\n profile_path,\n meta_path,\n }\n}\n\nfn proxy_origin_from_parts(listen_address: &str, listen_port: u16) -> String {\n let connect_host = match listen_address {\n \"0.0.0.0\" => \"127.0.0.1\",\n \"::\" => \"::1\",\n value => value,\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host.to_string()\n };\n\n format!(\"http://{}:{}\", connect_host_for_url, listen_port)\n}\n\n#[cfg(not(any(target_os = \"macos\", windows)))]\nfn unsupported_platform_error() -> AppError {\n AppError::localized(\n \"claude_desktop.unsupported_platform\",\n \"当前平台暂不支持 Claude Desktop 3P 配置。第一阶段仅支持 macOS 和 Windows。\",\n \"Claude Desktop 3P configuration is not supported on this platform yet. Phase 1 only supports macOS and Windows.\",\n )\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::database::Database;\n use crate::provider::{ClaudeDesktopModelRoute, ProviderMeta};\n use serde_json::json;\n use tempfile::TempDir;\n\n fn test_paths(home: &Path) -> ClaudeDesktopPaths {\n paths_from_dirs(\n home.join(\"Library\")\n .join(\"Application Support\")\n .join(\"Claude\"),\n home.join(\"Library\")\n .join(\"Application Support\")\n .join(\"Claude-3p\"),\n )\n }\n\n fn test_db() -> Database {\n Database::memory().expect(\"memory db\")\n }\n\n fn set_proxy_port(db: &Database, port: u16) {\n let config = crate::proxy::types::ProxyConfig {\n listen_port: port,\n ..Default::default()\n };\n futures::executor::block_on(db.update_proxy_config(config)).expect(\"update proxy config\");\n }\n\n fn direct_provider(id: &str) -> Provider {\n let mut provider = Provider::with_id(\n id.to_string(),\n \"Direct\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://gateway.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"test-token\",\n \"ANTHROPIC_MODEL\": \"ignored-by-desktop\"\n }\n }),\n Some(\"https://example.com\".to_string()),\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"anthropic\".to_string()),\n ..Default::default()\n });\n provider\n }\n\n #[test]\n fn proxy_gateway_base_url_rejects_unresolved_ephemeral_port() {\n let db = test_db();\n set_proxy_port(&db, 0);\n\n let err = proxy_gateway_base_url_from_db(&db)\n .expect_err(\"unresolved ephemeral port should not produce a :0 URL\");\n assert!(\n err.to_string().contains(\"真实监听端口\"),\n \"unexpected error: {err}\"\n );\n }\n\n fn official_provider() -> Provider {\n let mut provider = Provider::with_id(\n CLAUDE_DESKTOP_OFFICIAL_PROVIDER_ID.to_string(),\n \"Claude Desktop Official\".to_string(),\n json!({\"env\": {}}),\n Some(\"https://claude.ai/download\".to_string()),\n );\n provider.category = Some(\"official\".to_string());\n provider\n }\n\n fn proxy_provider(id: &str) -> Provider {\n let mut provider = direct_provider(id);\n provider.name = \"Proxy\".to_string();\n provider.meta = Some(ProviderMeta {\n claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),\n api_format: Some(\"openai_chat\".to_string()),\n claude_desktop_model_routes: std::collections::HashMap::from([(\n \"claude-sonnet-4-6\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"kimi-k2\".to_string(),\n label_override: Some(\"Kimi K2\".to_string()),\n supports_1m: Some(true),\n },\n )]),\n ..Default::default()\n });\n provider\n }\n\n fn mimo_anthropic_proxy_provider(id: &str) -> Provider {\n let mut provider = direct_provider(id);\n provider.name = \"MiMo Proxy\".to_string();\n provider.settings_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.xiaomimimo.com/anthropic\",\n \"ANTHROPIC_AUTH_TOKEN\": \"test-token\"\n }\n });\n provider.meta = Some(ProviderMeta {\n claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),\n api_format: Some(\"anthropic\".to_string()),\n claude_desktop_model_routes: std::collections::HashMap::from([(\n \"claude-sonnet-4-6\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"mimo-v2.5-pro\".to_string(),\n label_override: Some(\"MiMo v2.5 Pro\".to_string()),\n supports_1m: Some(true),\n },\n )]),\n ..Default::default()\n });\n provider\n }\n\n fn oauth_proxy_provider(id: &str, provider_type: &str, api_format: &str) -> Provider {\n let mut provider = Provider::with_id(\n id.to_string(),\n \"OAuth Proxy\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://oauth-upstream.example.com\"\n }\n }),\n Some(\"https://example.com\".to_string()),\n );\n provider.meta = Some(ProviderMeta {\n claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),\n api_format: Some(api_format.to_string()),\n provider_type: Some(provider_type.to_string()),\n claude_desktop_model_routes: std::collections::HashMap::from([(\n \"claude-sonnet-4-6\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"gpt-5.4\".to_string(),\n label_override: Some(\"GPT-5.4\".to_string()),\n supports_1m: Some(false),\n },\n )]),\n ..Default::default()\n });\n provider\n }\n\n fn direct_provider_with_models(id: &str) -> Provider {\n let mut provider = direct_provider(id);\n provider.meta = Some(ProviderMeta {\n claude_desktop_mode: Some(ClaudeDesktopMode::Direct),\n api_format: Some(\"anthropic\".to_string()),\n claude_desktop_model_routes: std::collections::HashMap::from([(\n \"claude-sonnet-4-6\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"claude-sonnet-4-6\".to_string(),\n label_override: None,\n supports_1m: Some(true),\n },\n )]),\n ..Default::default()\n });\n provider\n }\n\n #[test]\n fn claude_desktop_apply_writes_3p_profile_and_meta() {\n let temp = TempDir::new().expect(\"tempdir\");\n let paths = test_paths(temp.path());\n let provider = direct_provider(\"direct\");\n let db = test_db();\n\n apply_provider_to_paths(&db, &provider, &paths).expect(\"apply provider\");\n\n let normal: Value = read_json_file(&paths.normal_config_path).expect(\"read normal config\");\n let threep: Value = read_json_file(&paths.threep_config_path).expect(\"read 3p config\");\n let profile: Value = read_json_file(&paths.profile_path).expect(\"read profile\");\n let meta: Value = read_json_file(&paths.meta_path).expect(\"read meta\");\n\n assert_eq!(normal[\"deploymentMode\"], json!(\"3p\"));\n assert_eq!(threep[\"deploymentMode\"], json!(\"3p\"));\n assert_eq!(profile[\"inferenceProvider\"], json!(\"gateway\"));\n assert_eq!(\n profile[\"inferenceGatewayBaseUrl\"],\n json!(\"https://gateway.example.com\")\n );\n assert_eq!(profile[\"inferenceGatewayApiKey\"], json!(\"test-token\"));\n assert_eq!(profile[\"inferenceGatewayAuthScheme\"], json!(\"bearer\"));\n assert_eq!(profile[\"disableDeploymentModeChooser\"], json!(true));\n assert_eq!(profile[\"coworkEgressAllowedHosts\"], json!([\"*\"]));\n assert!(profile.get(\"inferenceModels\").is_none());\n assert_eq!(meta[\"appliedId\"], json!(PROFILE_ID));\n assert!(meta[\"entries\"]\n .as_array()\n .expect(\"entries\")\n .iter()\n .any(|entry| entry[\"id\"] == json!(PROFILE_ID) && entry[\"name\"] == json!(PROFILE_NAME)));\n }\n\n #[test]\n fn claude_desktop_direct_can_write_optional_safe_model_ids() {\n let temp = TempDir::new().expect(\"tempdir\");\n let paths = test_paths(temp.path());\n let provider = direct_provider_with_models(\"direct-models\");\n let db = test_db();\n\n apply_provider_to_paths(&db, &provider, &paths).expect(\"apply provider\");\n\n let profile: Value = read_json_file(&paths.profile_path).expect(\"read profile\");\n assert_eq!(\n profile[\"inferenceGatewayBaseUrl\"],\n json!(\"https://gateway.example.com\")\n );\n assert_eq!(\n profile[\"inferenceModels\"],\n json!([{ \"name\": \"claude-sonnet-4-6\", \"supports1m\": true }])\n );\n }\n\n #[test]\n fn claude_desktop_direct_rejects_model_mapping_to_non_claude_upstream() {\n let mut provider = direct_provider_with_models(\"direct-non-claude\");\n provider\n .meta\n .as_mut()\n .expect(\"meta\")\n .claude_desktop_model_routes\n .get_mut(\"claude-sonnet-4-6\")\n .expect(\"route\")\n .model = \"mimo-v2.5-pro\".to_string();\n\n let err = validate_provider(&provider).expect_err(\"direct mapping should fail\");\n assert!(err.to_string().contains(\"本地路由模式\"));\n }\n\n #[test]\n fn claude_desktop_proxy_apply_writes_local_gateway_profile_with_safe_models() {\n let temp = TempDir::new().expect(\"tempdir\");\n let paths = test_paths(temp.path());\n let provider = proxy_provider(\"proxy\");\n let db = test_db();\n\n apply_provider_to_paths(&db, &provider, &paths).expect(\"apply proxy provider\");\n\n let profile: Value = read_json_file(&paths.profile_path).expect(\"read profile\");\n assert_eq!(\n profile[\"inferenceGatewayBaseUrl\"],\n json!(\"http://127.0.0.1:15721/claude-desktop\")\n );\n assert_eq!(profile[\"inferenceGatewayAuthScheme\"], json!(\"bearer\"));\n assert_eq!(profile[\"coworkEgressAllowedHosts\"], json!([\"*\"]));\n assert_ne!(profile[\"inferenceGatewayApiKey\"], json!(\"test-token\"));\n assert!(profile[\"inferenceGatewayApiKey\"]\n .as_str()\n .expect(\"gateway token\")\n .starts_with(\"ccs-\"));\n assert_eq!(\n profile[\"inferenceModels\"],\n json!([{ \"name\": \"claude-sonnet-4-6\", \"labelOverride\": \"Kimi K2\", \"supports1m\": true }])\n );\n assert!(!profile.to_string().contains(\"kimi-k2\"));\n }\n\n #[test]\n fn claude_desktop_proxy_accepts_managed_oauth_providers_without_static_key() {\n for (provider_type, api_format) in [\n (\"github_copilot\", \"openai_chat\"),\n (\"codex_oauth\", \"openai_responses\"),\n ] {\n let provider = oauth_proxy_provider(provider_type, provider_type, api_format);\n validate_proxy_provider(&provider).expect(\"oauth proxy provider should validate\");\n\n let temp = TempDir::new().expect(\"tempdir\");\n let paths = test_paths(temp.path());\n let db = test_db();\n apply_provider_to_paths(&db, &provider, &paths).expect(\"apply oauth proxy provider\");\n\n let profile: Value = read_json_file(&paths.profile_path).expect(\"read profile\");\n assert_eq!(\n profile[\"inferenceGatewayBaseUrl\"],\n json!(\"http://127.0.0.1:15721/claude-desktop\")\n );\n assert_eq!(\n profile[\"inferenceModels\"],\n json!([{ \"name\": \"claude-sonnet-4-6\", \"labelOverride\": \"GPT-5.4\" }])\n );\n }\n }\n\n #[test]\n fn claude_desktop_proxy_maps_known_route_and_rejects_unknown_route() {\n let provider = proxy_provider(\"proxy\");\n\n let mapped = map_proxy_request_model(\n json!({\"model\": \"claude-sonnet-4-6\", \"messages\": []}),\n &provider,\n )\n .expect(\"map route\");\n assert_eq!(mapped[\"model\"], json!(\"kimi-k2\"));\n\n let models = model_list_response(&provider).expect(\"model list\");\n assert_eq!(models[\"data\"][0][\"id\"], json!(\"claude-sonnet-4-6\"));\n assert_eq!(models[\"data\"][0][\"supports1m\"], json!(true));\n\n let err = map_proxy_request_model(json!({\"model\": \"claude-opus-4-8\"}), &provider)\n .expect_err(\"unknown route should fail\");\n assert!(err.to_string().contains(\"claude-opus-4-8\"));\n }\n\n #[test]\n fn claude_desktop_proxy_maps_dated_role_alias_via_keyword() {\n // 复现反馈:Claude Desktop 子 agent 请求带发布日期后缀的完整官方名\n // (claude-haiku-4-5-20251001),与 manifest 的简短 route_id(claude-haiku-4-5)\n // 不精确相等,旧逻辑会报 route_unknown。角色关键词回落应将其映射到 Haiku 档。\n let mut provider = proxy_provider(\"proxy\");\n provider\n .meta\n .as_mut()\n .expect(\"meta\")\n .claude_desktop_model_routes = std::collections::HashMap::from([\n (\n \"claude-sonnet-4-6\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"deepseek-v4-pro\".to_string(),\n label_override: None,\n supports_1m: Some(true),\n },\n ),\n (\n \"claude-opus-4-8\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"deepseek-v4-pro\".to_string(),\n label_override: None,\n supports_1m: Some(true),\n },\n ),\n (\n \"claude-haiku-4-5\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"deepseek-v4-flash\".to_string(),\n label_override: None,\n supports_1m: Some(true),\n },\n ),\n ]);\n\n let mapped = map_proxy_request_model(\n json!({\"model\": \"claude-haiku-4-5-20251001\", \"messages\": []}),\n &provider,\n )\n .expect(\"dated Haiku alias should map via role keyword\");\n assert_eq!(mapped[\"model\"], json!(\"deepseek-v4-flash\"));\n\n let mapped_sonnet = map_proxy_request_model(\n json!({\"model\": \"claude-sonnet-4-5-20250101\", \"messages\": []}),\n &provider,\n )\n .expect(\"dated Sonnet alias should map via role keyword\");\n assert_eq!(mapped_sonnet[\"model\"], json!(\"deepseek-v4-pro\"));\n\n // 不含任何角色关键词的模型仍然报错,避免被误映射。\n let err = map_proxy_request_model(json!({\"model\": \"gpt-5\"}), &provider)\n .expect_err(\"model without a role keyword should still fail\");\n assert!(err.to_string().contains(\"gpt-5\"));\n }\n\n #[test]\n fn claude_desktop_proxy_maps_fable_to_opus_tier() {\n // issue #4026/#4049:老用户只配 Sonnet/Opus/Haiku 三档、未显式配置\n // fable 档时,fable 请求按官方分类器降级方向回落到 opus 档兜底。\n let mut provider = proxy_provider(\"proxy\");\n provider\n .meta\n .as_mut()\n .expect(\"meta\")\n .claude_desktop_model_routes = std::collections::HashMap::from([\n (\n \"claude-opus-4-8\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"upstream-opus\".to_string(),\n label_override: None,\n supports_1m: Some(true),\n },\n ),\n (\n \"claude-sonnet-4-6\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"upstream-sonnet\".to_string(),\n label_override: None,\n supports_1m: Some(true),\n },\n ),\n ]);\n\n let mapped = map_proxy_request_model(\n json!({\"model\": \"claude-fable-5\", \"messages\": []}),\n &provider,\n )\n .expect(\"fable should fall back to the opus tier\");\n assert_eq!(mapped[\"model\"], json!(\"upstream-opus\"));\n\n // 带 [1m] 标记与日期后缀的形态也应命中同一回落。\n let mapped_one_m = map_proxy_request_model(\n json!({\"model\": \"claude-fable-5[1m]\", \"messages\": []}),\n &provider,\n )\n .expect(\"fable with [1m] marker should fall back to the opus tier\");\n assert_eq!(mapped_one_m[\"model\"], json!(\"upstream-opus\"));\n\n let mapped_dated = map_proxy_request_model(\n json!({\"model\": \"claude-fable-5-20260609\", \"messages\": []}),\n &provider,\n )\n .expect(\"dated fable alias should fall back to the opus tier\");\n assert_eq!(mapped_dated[\"model\"], json!(\"upstream-opus\"));\n }\n\n #[test]\n fn claude_desktop_proxy_fable_without_opus_route_still_errors() {\n // 没有 opus 档可回落时保持精确报错语义,不静默落到其他档。\n let mut provider = proxy_provider(\"proxy\");\n provider\n .meta\n .as_mut()\n .expect(\"meta\")\n .claude_desktop_model_routes = std::collections::HashMap::from([(\n \"claude-sonnet-4-6\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"upstream-sonnet\".to_string(),\n label_override: None,\n supports_1m: Some(true),\n },\n )]);\n\n let err = map_proxy_request_model(\n json!({\"model\": \"claude-fable-5\", \"messages\": []}),\n &provider,\n )\n .expect_err(\"fable without an opus route should fail\");\n assert!(err.to_string().contains(\"claude-fable-5\"));\n }\n\n #[test]\n fn claude_desktop_proxy_maps_fable_to_dedicated_route() {\n // Desktop 1.12603.1+ fail-all 校验已放行 claude-fable-5,用户可显式配置\n // 独立 fable 档;此时 fable 请求精确命中 fable 档,不再降级到 opus。\n let mut provider = proxy_provider(\"proxy\");\n provider\n .meta\n .as_mut()\n .expect(\"meta\")\n .claude_desktop_model_routes = std::collections::HashMap::from([\n (\n \"claude-opus-4-8\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"upstream-opus\".to_string(),\n label_override: None,\n supports_1m: Some(true),\n },\n ),\n (\n \"claude-fable-5\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"upstream-fable\".to_string(),\n label_override: None,\n supports_1m: Some(true),\n },\n ),\n ]);\n\n // 精确匹配优先命中 fable 档\n let mapped = map_proxy_request_model(\n json!({\"model\": \"claude-fable-5\", \"messages\": []}),\n &provider,\n )\n .expect(\"explicit fable route should match\");\n assert_eq!(mapped[\"model\"], json!(\"upstream-fable\"));\n\n // 带日期后缀经角色关键词回落仍归 fable 档,而非降级 opus\n let mapped_dated = map_proxy_request_model(\n json!({\"model\": \"claude-fable-5-20260609\", \"messages\": []}),\n &provider,\n )\n .expect(\"dated fable alias should map via fable role keyword\");\n assert_eq!(mapped_dated[\"model\"], json!(\"upstream-fable\"));\n }\n\n #[test]\n fn claude_desktop_proxy_accepts_opus_4_7_4_8_alias_during_rollout() {\n let mut provider = proxy_provider(\"proxy\");\n let current_routes = std::collections::HashMap::from([(\n \"claude-opus-4-8\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"upstream-opus-new\".to_string(),\n label_override: None,\n supports_1m: Some(true),\n },\n )]);\n provider\n .meta\n .as_mut()\n .expect(\"meta\")\n .claude_desktop_model_routes = current_routes;\n\n let mapped = map_proxy_request_model(\n json!({\"model\": \"claude-opus-4-7\", \"messages\": []}),\n &provider,\n )\n .expect(\"legacy Opus route should map to current route\");\n assert_eq!(mapped[\"model\"], json!(\"upstream-opus-new\"));\n\n let legacy_routes = std::collections::HashMap::from([(\n \"claude-opus-4-7\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"upstream-opus-legacy\".to_string(),\n label_override: None,\n supports_1m: Some(true),\n },\n )]);\n provider\n .meta\n .as_mut()\n .expect(\"meta\")\n .claude_desktop_model_routes = legacy_routes;\n\n let mapped = map_proxy_request_model(\n json!({\"model\": \"claude-opus-4-8\", \"messages\": []}),\n &provider,\n )\n .expect(\"current Opus route should map to legacy saved route\");\n assert_eq!(mapped[\"model\"], json!(\"upstream-opus-legacy\"));\n }\n\n #[test]\n fn claude_desktop_mimo_anthropic_rewrites_redacted_thinking_for_tool_history() {\n let provider = mimo_anthropic_proxy_provider(\"mimo\");\n\n let mapped = map_proxy_request_model(\n json!({\n \"model\": \"claude-sonnet-4-6\",\n \"messages\": [{\n \"role\": \"assistant\",\n \"content\": [\n {\"type\": \"redacted_thinking\", \"data\": \"opaque\"},\n {\"type\": \"tool_use\", \"id\": \"call_1\", \"name\": \"read_file\", \"input\": {\"path\": \"README.md\"}}\n ]\n }]\n }),\n &provider,\n )\n .expect(\"map MiMo route\");\n\n assert_eq!(mapped[\"model\"], json!(\"mimo-v2.5-pro\"));\n assert_eq!(\n mapped[\"messages\"][0][\"content\"][0][\"type\"],\n json!(\"thinking\")\n );\n assert_eq!(\n mapped[\"messages\"][0][\"content\"][0][\"thinking\"],\n json!(\"[redacted thinking]\")\n );\n assert_eq!(\n mapped[\"messages\"][0][\"content\"][1][\"type\"],\n json!(\"tool_use\")\n );\n }\n\n #[test]\n fn claude_desktop_mimo_anthropic_injects_thinking_for_tool_history_without_one() {\n let provider = mimo_anthropic_proxy_provider(\"mimo\");\n\n let mapped = map_proxy_request_model(\n json!({\n \"model\": \"claude-sonnet-4-6\",\n \"messages\": [{\n \"role\": \"assistant\",\n \"content\": [\n {\"type\": \"tool_use\", \"id\": \"call_1\", \"name\": \"read_file\", \"input\": {\"path\": \"README.md\"}}\n ]\n }]\n }),\n &provider,\n )\n .expect(\"map MiMo route\");\n\n assert_eq!(\n mapped[\"messages\"][0][\"content\"][0][\"type\"],\n json!(\"thinking\")\n );\n assert_eq!(\n mapped[\"messages\"][0][\"content\"][0][\"thinking\"],\n json!(\"tool call\")\n );\n assert_eq!(\n mapped[\"messages\"][0][\"content\"][1][\"type\"],\n json!(\"tool_use\")\n );\n }\n\n #[test]\n fn claude_desktop_mimo_anthropic_keeps_thinking_text_but_drops_signature() {\n let provider = mimo_anthropic_proxy_provider(\"mimo\");\n\n let mapped = map_proxy_request_model(\n json!({\n \"model\": \"claude-sonnet-4-6\",\n \"messages\": [{\n \"role\": \"assistant\",\n \"content\": [\n {\"type\": \"thinking\", \"thinking\": \"Need to inspect the file.\", \"signature\": \"anthropic-signature\"},\n {\"type\": \"tool_use\", \"id\": \"call_1\", \"name\": \"read_file\", \"input\": {\"path\": \"README.md\"}}\n ]\n }]\n }),\n &provider,\n )\n .expect(\"map MiMo route\");\n\n assert_eq!(\n mapped[\"messages\"][0][\"content\"][0][\"thinking\"],\n json!(\"Need to inspect the file.\")\n );\n assert!(mapped[\"messages\"][0][\"content\"][0]\n .get(\"signature\")\n .is_none());\n }\n\n #[test]\n fn claude_desktop_proxy_repairs_legacy_unsafe_route_without_colliding() {\n let mut provider = proxy_provider(\"proxy\");\n provider.meta = Some(ProviderMeta {\n claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),\n api_format: Some(\"openai_chat\".to_string()),\n claude_desktop_model_routes: std::collections::HashMap::from([\n (\n \"claude-deepseek-v4-pro\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"deepseek-v4-pro\".to_string(),\n label_override: None,\n supports_1m: Some(true),\n },\n ),\n (\n \"claude-old\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"legacy-upstream\".to_string(),\n label_override: None,\n supports_1m: Some(false),\n },\n ),\n (\n \"claude-sonnet-5\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"claude-sonnet-5\".to_string(),\n label_override: None,\n supports_1m: Some(false),\n },\n ),\n ]),\n ..Default::default()\n });\n\n let routes = proxy_model_routes(&provider).expect(\"routes\");\n assert_eq!(routes.len(), 3);\n let repaired = routes\n .iter()\n .find(|route| route.upstream_model == \"deepseek-v4-pro\")\n .expect(\"repaired route\");\n assert_eq!(repaired.route_id, \"claude-opus-4-8\");\n assert_eq!(repaired.label_override.as_deref(), Some(\"deepseek-v4-pro\"));\n assert!(repaired.supports_1m);\n let repaired_old = routes\n .iter()\n .find(|route| route.upstream_model == \"legacy-upstream\")\n .expect(\"legacy route should be repaired\");\n assert_eq!(repaired_old.route_id, \"claude-haiku-4-5\");\n assert_eq!(\n repaired_old.label_override.as_deref(),\n Some(\"legacy-upstream\")\n );\n\n let mapped = map_proxy_request_model(\n json!({\"model\": \"claude-opus-4-8\", \"messages\": []}),\n &provider,\n )\n .expect(\"map repaired route\");\n assert_eq!(mapped[\"model\"], json!(\"deepseek-v4-pro\"));\n\n let legacy_mapped =\n map_proxy_request_model(json!({\"model\": \"claude-old\", \"messages\": []}), &provider)\n .expect(\"map stale profile route\");\n assert_eq!(legacy_mapped[\"model\"], json!(\"legacy-upstream\"));\n }\n\n #[test]\n fn claude_desktop_proxy_strips_1m_suffix_before_route_lookup() {\n let mut provider = proxy_provider(\"proxy\");\n provider\n .meta\n .as_mut()\n .expect(\"meta\")\n .claude_desktop_model_routes = std::collections::HashMap::from([\n (\n \"claude-sonnet-4-6\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"upstream-sonnet\".to_string(),\n label_override: None,\n supports_1m: Some(true),\n },\n ),\n (\n \"claude-opus-4-8\".to_string(),\n ClaudeDesktopModelRoute {\n model: \"upstream-opus\".to_string(),\n label_override: None,\n supports_1m: Some(true),\n },\n ),\n ]);\n\n let mapped = map_proxy_request_model(\n json!({\"model\": \"claude-opus-4-8[1m]\", \"messages\": []}),\n &provider,\n )\n .expect(\"compact 1M suffix should map to Opus route\");\n assert_eq!(mapped[\"model\"], json!(\"upstream-opus\"));\n\n let mapped = map_proxy_request_model(\n json!({\"model\": \"claude-sonnet-4-6 [1M]\", \"messages\": []}),\n &provider,\n )\n .expect(\"spaced uppercase 1M suffix should map to Sonnet route\");\n assert_eq!(mapped[\"model\"], json!(\"upstream-sonnet\"));\n\n let err = map_proxy_request_model(json!({\"model\": \"gpt-5[1m]\", \"messages\": []}), &provider)\n .expect_err(\"non-Claude route should still fail after stripping 1M suffix\");\n assert!(err.to_string().contains(\"gpt-5[1m]\"));\n }\n\n #[test]\n fn claude_desktop_rejects_1m_suffix_as_model_id() {\n assert!(!is_claude_safe_model_id(\"claude-sonnet-4-6 [1m]\"));\n assert!(!is_claude_safe_model_id(\" claude-sonnet-4-6 [1M] \"));\n assert!(!is_claude_safe_model_id(\"claude-old\"));\n assert!(!is_claude_safe_model_id(\"claude-3-5-sonnet-20241022\"));\n assert!(!is_claude_safe_model_id(\"claude-deepseek-v4-pro\"));\n assert!(!is_claude_safe_model_id(\"claude-gpt-5-4\"));\n assert!(!is_claude_safe_model_id(\"claude-\"));\n assert!(!is_claude_safe_model_id(\"anthropic/claude-\"));\n assert!(!is_claude_safe_model_id(\"sonnet\"));\n assert!(!is_claude_safe_model_id(\"sonnet-\"));\n // 角色前缀后无实际标识的退化值必须拒绝\n assert!(!is_claude_safe_model_id(\"claude-sonnet-\"));\n assert!(!is_claude_safe_model_id(\"claude-opus-\"));\n assert!(!is_claude_safe_model_id(\"anthropic/claude-haiku-\"));\n assert!(is_claude_safe_model_id(\" claude-sonnet-4-6 \"));\n assert!(is_claude_safe_model_id(\"anthropic/claude-opus-4-8\"));\n }\n\n #[test]\n fn claude_desktop_apply_rolls_back_when_profile_write_fails() {\n let temp = TempDir::new().expect(\"tempdir\");\n let paths = test_paths(temp.path());\n let provider = direct_provider(\"direct\");\n let db = test_db();\n\n write_json_file(\n &paths.normal_config_path,\n &json!({\"deploymentMode\": \"1p\", \"normal\": true}),\n )\n .expect(\"write normal\");\n write_json_file(\n &paths.threep_config_path,\n &json!({\"deploymentMode\": \"1p\", \"threep\": true}),\n )\n .expect(\"write 3p\");\n fs::write(&paths.config_library_path, \"not a directory\").expect(\"block profile parent\");\n\n apply_provider_to_paths(&db, &provider, &paths).expect_err(\"apply should fail\");\n\n let normal: Value = read_json_file(&paths.normal_config_path).expect(\"read normal config\");\n let threep: Value = read_json_file(&paths.threep_config_path).expect(\"read 3p config\");\n\n assert_eq!(normal, json!({\"deploymentMode\": \"1p\", \"normal\": true}));\n assert_eq!(threep, json!({\"deploymentMode\": \"1p\", \"threep\": true}));\n assert!(!paths.profile_path.exists());\n }\n\n #[test]\n fn claude_desktop_write_meta_recovers_non_object_meta_file() {\n let temp = TempDir::new().expect(\"tempdir\");\n let paths = test_paths(temp.path());\n if let Some(parent) = paths.meta_path.parent() {\n fs::create_dir_all(parent).expect(\"create parent\");\n }\n fs::write(&paths.meta_path, \"[]\").expect(\"write invalid meta shape\");\n\n write_meta(&paths.meta_path, Some(PROFILE_ID)).expect(\"write meta\");\n\n let meta: Value = read_json_file(&paths.meta_path).expect(\"read meta\");\n assert_eq!(meta[\"appliedId\"], json!(PROFILE_ID));\n assert!(meta[\"entries\"].as_array().is_some());\n }\n\n #[test]\n fn claude_desktop_restore_switches_to_1p_and_removes_cc_switch_profile() {\n let temp = TempDir::new().expect(\"tempdir\");\n let paths = test_paths(temp.path());\n let provider = direct_provider(\"direct\");\n let db = test_db();\n\n apply_provider_to_paths(&db, &provider, &paths).expect(\"apply provider\");\n restore_official_at_paths(&paths).expect(\"restore official\");\n\n let normal: Value = read_json_file(&paths.normal_config_path).expect(\"read normal config\");\n let threep: Value = read_json_file(&paths.threep_config_path).expect(\"read 3p config\");\n let meta: Value = read_json_file(&paths.meta_path).expect(\"read meta\");\n\n assert_eq!(normal[\"deploymentMode\"], json!(\"1p\"));\n assert_eq!(threep[\"deploymentMode\"], json!(\"1p\"));\n assert!(!paths.profile_path.exists());\n assert!(meta.get(\"appliedId\").is_none());\n assert!(!meta[\"entries\"]\n .as_array()\n .expect(\"entries\")\n .iter()\n .any(|entry| entry[\"id\"] == json!(PROFILE_ID)));\n }\n\n #[test]\n fn claude_desktop_official_provider_restores_1p_mode() {\n let temp = TempDir::new().expect(\"tempdir\");\n let paths = test_paths(temp.path());\n let direct = direct_provider(\"direct\");\n let db = test_db();\n\n apply_provider_to_paths(&db, &direct, &paths).expect(\"apply direct provider\");\n apply_provider_to_paths(&db, &official_provider(), &paths)\n .expect(\"restore official provider\");\n\n let normal: Value = read_json_file(&paths.normal_config_path).expect(\"read normal config\");\n let threep: Value = read_json_file(&paths.threep_config_path).expect(\"read 3p config\");\n let meta: Value = read_json_file(&paths.meta_path).expect(\"read meta\");\n\n assert_eq!(normal[\"deploymentMode\"], json!(\"1p\"));\n assert_eq!(threep[\"deploymentMode\"], json!(\"1p\"));\n assert!(!paths.profile_path.exists());\n assert!(meta.get(\"appliedId\").is_none());\n }\n\n #[test]\n fn claude_desktop_compatibility_filters_non_direct_providers() {\n let direct = direct_provider(\"direct\");\n assert!(is_compatible_direct_provider(&direct));\n\n let mut claude_official = Provider::with_id(\n \"claude-official\".to_string(),\n \"Claude Official\".to_string(),\n json!({\"env\": {}}),\n Some(\"https://www.anthropic.com/claude-code\".to_string()),\n );\n claude_official.category = Some(\"official\".to_string());\n assert!(!is_compatible_direct_provider(&claude_official));\n\n let mut openai_format = direct_provider(\"openai\");\n openai_format.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n assert!(!is_compatible_direct_provider(&openai_format));\n\n let mut copilot = direct_provider(\"copilot\");\n copilot.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n assert!(!is_compatible_direct_provider(&copilot));\n\n let mut full_url = direct_provider(\"full_url\");\n full_url.meta = Some(ProviderMeta {\n is_full_url: Some(true),\n ..Default::default()\n });\n assert!(!is_compatible_direct_provider(&full_url));\n\n let missing_bearer = Provider::with_id(\n \"x-api-key\".to_string(),\n \"x-api-key\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://gateway.example.com\",\n \"ANTHROPIC_API_KEY\": \"sk-ant\"\n }\n }),\n None,\n );\n assert!(!is_compatible_direct_provider(&missing_bearer));\n }\n}\n"}}, "src/config/opencodeProviderPresets.ts": {"1e398a3709f03db3fe5d989827090386b9dcd3be": {"t": 1785595982.8882394, "n": 52925, "h": "c11a9576726a6fd7", "c": "import type { ProviderCategory, OpenCodeProviderConfig } from \"../types\";\nimport type { PresetTheme, TemplateValueConfig } from \"./claudeProviderPresets\";\n\nexport interface OpenCodeProviderPreset {\n name: string;\n nameKey?: string; // i18n key for localized display name\n websiteUrl: string;\n apiKeyUrl?: string;\n settingsConfig: OpenCodeProviderConfig;\n isOfficial?: boolean;\n isPartner?: boolean;\n primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形\n partnerPromotionKey?: string;\n category?: ProviderCategory;\n templateValues?: Record<string, TemplateValueConfig>;\n theme?: PresetTheme;\n icon?: string;\n iconColor?: string;\n isCustomTemplate?: boolean;\n}\n\nexport const opencodeNpmPackages = [\n { value: \"@ai-sdk/openai\", label: \"OpenAI Responses\" },\n { value: \"@ai-sdk/openai-compatible\", label: \"OpenAI Compatible\" },\n { value: \"@ai-sdk/anthropic\", label: \"Anthropic\" },\n { value: \"@ai-sdk/amazon-bedrock\", label: \"Amazon Bedrock\" },\n { value: \"@ai-sdk/google\", label: \"Google (Gemini)\" },\n] as const;\n\nexport interface PresetModelVariant {\n id: string;\n name?: string;\n contextLimit?: number;\n outputLimit?: number;\n modalities?: { input: string[]; output: string[] };\n options?: Record<string, unknown>;\n variants?: Record<string, Record<string, unknown>>;\n}\n\nexport const OPENCODE_PRESET_MODEL_VARIANTS: Record<\n string,\n PresetModelVariant[]\n> = {\n \"@ai-sdk/openai-compatible\": [\n {\n id: \"MiniMax-M2.7\",\n name: \"MiniMax M2.7\",\n contextLimit: 204800,\n outputLimit: 131072,\n modalities: { input: [\"text\"], output: [\"text\"] },\n },\n {\n id: \"glm-5.1\",\n name: \"GLM 5.1\",\n contextLimit: 204800,\n outputLimit: 131072,\n modalities: { input: [\"text\"], output: [\"text\"] },\n },\n {\n id: \"kimi-k2.6\",\n name: \"Kimi K2.6\",\n contextLimit: 262144,\n outputLimit: 262144,\n modalities: { input: [\"text\", \"image\", \"video\"], output: [\"text\"] },\n },\n {\n id: \"step-3.5-flash-2603\",\n name: \"Step 3.5 Flash 2603\",\n contextLimit: 262144,\n },\n {\n id: \"step-3.5-flash\",\n name: \"Step 3.5 Flash\",\n contextLimit: 262144,\n },\n ],\n \"@ai-sdk/google\": [\n {\n id: \"gemini-2.5-flash-lite\",\n name: \"Gemini 2.5 Flash Lite\",\n contextLimit: 1048576,\n outputLimit: 65536,\n modalities: {\n input: [\"text\", \"image\", \"pdf\", \"video\", \"audio\"],\n output: [\"text\"],\n },\n variants: {\n auto: {\n thinkingConfig: { includeThoughts: true, thinkingBudget: -1 },\n },\n \"no-thinking\": { thinkingConfig: { thinkingBudget: 0 } },\n },\n },\n {\n id: \"gemini-3.5-flash\",\n name: \"Gemini 3.5 Flash\",\n contextLimit: 1048576,\n outputLimit: 65536,\n modalities: {\n input: [\"text\", \"image\", \"pdf\", \"video\", \"audio\"],\n output: [\"text\"],\n },\n variants: {\n minimal: {\n thinkingConfig: { includeThoughts: true, thinkingLevel: \"minimal\" },\n },\n low: {\n thinkingConfig: { includeThoughts: true, thinkingLevel: \"low\" },\n },\n medium: {\n thinkingConfig: { includeThoughts: true, thinkingLevel: \"medium\" },\n },\n high: {\n thinkingConfig: { includeThoughts: true, thinkingLevel: \"high\" },\n },\n },\n },\n ],\n \"@ai-sdk/openai\": [\n {\n id: \"gpt-5.5\",\n name: \"GPT-5.5\",\n contextLimit: 400000,\n outputLimit: 128000,\n modalities: { input: [\"text\", \"image\"], output: [\"text\"] },\n variants: {\n low: {\n reasoningEffort: \"low\",\n reasoningSummary: \"auto\",\n textVerbosity: \"medium\",\n },\n medium: {\n reasoningEffort: \"medium\",\n reasoningSummary: \"auto\",\n textVerbosity: \"medium\",\n },\n high: {\n reasoningEffort: \"high\",\n reasoningSummary: \"auto\",\n textVerbosity: \"medium\",\n },\n xhigh: {\n reasoningEffort: \"xhigh\",\n reasoningSummary: \"auto\",\n textVerbosity: \"medium\",\n },\n },\n },\n ],\n \"@ai-sdk/amazon-bedrock\": [\n {\n id: \"global.anthropic.claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextLimit: 1000000,\n outputLimit: 128000,\n modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n },\n {\n id: \"global.anthropic.claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextLimit: 1000000,\n outputLimit: 64000,\n modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n },\n {\n id: \"global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n name: \"Claude Haiku 4.5\",\n contextLimit: 200000,\n outputLimit: 64000,\n modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n },\n {\n id: \"us.amazon.nova-pro-v1:0\",\n name: \"Amazon Nova Pro\",\n contextLimit: 300000,\n outputLimit: 5000,\n modalities: { input: [\"text\", \"image\"], output: [\"text\"] },\n },\n {\n id: \"us.meta.llama4-maverick-17b-instruct-v1:0\",\n name: \"Meta Llama 4 Maverick\",\n contextLimit: 131072,\n outputLimit: 131072,\n modalities: { input: [\"text\"], output: [\"text\"] },\n },\n {\n id: \"us.deepseek.r1-v1:0\",\n name: \"DeepSeek R1\",\n contextLimit: 131072,\n outputLimit: 131072,\n modalities: { input: [\"text\"], output: [\"text\"] },\n },\n ],\n \"@ai-sdk/anthropic\": [\n {\n id: \"claude-sonnet-4-5-20250929\",\n name: \"Claude Sonnet 4.5\",\n contextLimit: 200000,\n outputLimit: 64000,\n modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n variants: {\n low: { effort: \"low\" },\n medium: { effort: \"medium\" },\n high: { effort: \"high\" },\n },\n },\n {\n id: \"claude-opus-4-5-20251101\",\n name: \"Claude Opus 4.5\",\n contextLimit: 200000,\n outputLimit: 64000,\n modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n variants: {\n low: { thinking: { budgetTokens: 5000, type: \"enabled\" } },\n medium: { thinking: { budgetTokens: 13000, type: \"enabled\" } },\n high: { thinking: { budgetTokens: 18000, type: \"enabled\" } },\n },\n },\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextLimit: 1000000,\n outputLimit: 128000,\n modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n variants: {\n low: { effort: \"low\" },\n medium: { effort: \"medium\" },\n high: { effort: \"high\" },\n max: { effort: \"max\" },\n },\n },\n {\n id: \"claude-haiku-4-5-20251001\",\n name: \"Claude Haiku 4.5\",\n contextLimit: 200000,\n outputLimit: 64000,\n modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n },\n {\n id: \"gemini-claude-opus-4-5-thinking\",\n name: \"Antigravity - Claude Opus 4.5\",\n contextLimit: 200000,\n outputLimit: 64000,\n modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n variants: {\n low: { effort: \"low\" },\n medium: { effort: \"medium\" },\n high: { effort: \"high\" },\n },\n },\n {\n id: \"gemini-claude-sonnet-4-5-thinking\",\n name: \"Antigravity - Claude Sonnet 4.5\",\n contextLimit: 200000,\n outputLimit: 64000,\n modalities: { input: [\"text\", \"image\", \"pdf\"], output: [\"text\"] },\n variants: {\n low: { thinking: { budgetTokens: 5000, type: \"enabled\" } },\n medium: { thinking: { budgetTokens: 13000, type: \"enabled\" } },\n high: { thinking: { budgetTokens: 18000, type: \"enabled\" } },\n },\n },\n ],\n};\n\n/**\n * Look up preset metadata for a model by npm package and model ID.\n * Returns enrichment fields (options, limit, modalities) that can be\n * merged into a model definition when the user's config doesn't already\n * provide them.\n */\nexport function getPresetModelDefaults(\n npm: string,\n modelId: string,\n): PresetModelVariant | undefined {\n const models = OPENCODE_PRESET_MODEL_VARIANTS[npm];\n if (!models) return undefined;\n return models.find((m) => m.id === modelId);\n}\n\nexport const opencodeProviderPresets: OpenCodeProviderPreset[] = [\n {\n name: \"Shengsuanyun\",\n nameKey: \"providerForm.presets.shengsuanyun\",\n websiteUrl: \"https://www.shengsuanyun.com/?from=CH_4HHXMRYF\",\n apiKeyUrl: \"https://www.shengsuanyun.com/?from=CH_4HHXMRYF\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"Shengsuanyun\",\n options: {\n baseURL: \"https://router.shengsuanyun.com/api/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"anthropic/claude-opus-4.8\": { name: \"Claude Opus 4.8\" },\n \"anthropic/claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n },\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"shengsuanyun\",\n icon: \"shengsuanyun\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Qiniu\",\n nameKey: \"providerForm.presets.qiniu\",\n websiteUrl: \"https://s.qiniu.com/nMvAvy\",\n apiKeyUrl: \"https://s.qiniu.com/nMvAvy\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"Qiniu\",\n options: {\n baseURL: \"https://api.qnaigc.com/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"gpt-5.5\": { name: \"GPT-5.5\" },\n },\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"qiniu\",\n icon: \"qiniu\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"FennoAI\",\n websiteUrl: \"https://api.fenno.ai\",\n apiKeyUrl:\n \"https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"FennoAI\",\n options: {\n baseURL: \"https://api.fenno.ai/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"gpt-5.5\": { name: \"GPT-5.5\" },\n },\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"fenno\",\n icon: \"fenno\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"ZetaAPI\",\n websiteUrl: \"https://zetaapi.ai\",\n apiKeyUrl: \"https://zetaapi.ai/go/ccs\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"ZetaAPI\",\n options: {\n baseURL: \"https://api.zetaapi.ai/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"gpt-5.5\": { name: \"GPT-5.5\" },\n },\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"zetaapi\",\n icon: \"zetaapi\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"TeamoRouter\",\n websiteUrl: \"https://teamorouter.com\",\n apiKeyUrl:\n \"https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"TeamoRouter\",\n options: {\n baseURL: \"https://api.teamorouter.com/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"gpt-5.5\": { name: \"GPT-5.5\" },\n },\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"teamorouter\",\n icon: \"teamorouter\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Amux\",\n websiteUrl: \"https://amux.ai\",\n apiKeyUrl: \"https://amux.ai\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"Amux\",\n options: {\n baseURL: \"https://api.amux.ai/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"gpt-5.5\": { name: \"GPT-5.5\" },\n },\n },\n category: \"aggregator\",\n icon: \"amux\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Code0\",\n websiteUrl: \"https://code0.ai\",\n apiKeyUrl: \"https://code0.ai/agent/register/B2XHxGjGmRvqgznY\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"Code0\",\n options: {\n baseURL: \"https://code0.ai/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"gpt-5.5\": { name: \"GPT-5.5\" },\n },\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"code0\",\n icon: \"code0\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"NekoCode\",\n websiteUrl: \"https://nekocode.ai\",\n apiKeyUrl: \"https://nekocode.ai?aff=CCSWITCH\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"NekoCode\",\n options: {\n baseURL: \"https://nekocode.ai/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"gpt-5.5\": { name: \"GPT-5.5\" },\n },\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"nekocode\",\n icon: \"nekocode\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"火山Agentplan\",\n websiteUrl:\n \"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch\",\n apiKeyUrl:\n \"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"火山Agentplan\",\n options: {\n baseURL: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"ark-code-latest\": {\n name: \"Ark Code Latest\",\n },\n },\n },\n category: \"cn_official\",\n isPartner: true,\n partnerPromotionKey: \"volcengine_agentplan\",\n icon: \"huoshan\",\n iconColor: \"#3370FF\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"BytePlus\",\n websiteUrl:\n \"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch\",\n apiKeyUrl:\n \"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"BytePlus\",\n options: {\n baseURL: \"https://ark.ap-southeast.bytepluses.com/api/coding/v3\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"ark-code-latest\": {\n name: \"Ark Code Latest\",\n },\n },\n },\n category: \"cn_official\",\n isPartner: true,\n partnerPromotionKey: \"byteplus\",\n icon: \"byteplus\",\n iconColor: \"#3370FF\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"DouBaoSeed\",\n websiteUrl:\n \"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch\",\n apiKeyUrl:\n \"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"DouBaoSeed\",\n options: {\n baseURL: \"https://ark.cn-beijing.volces.com/api/v3\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"doubao-seed-2-1-pro-260628\": {\n name: \"Doubao Seed 2.1 Pro\",\n },\n },\n },\n category: \"cn_official\",\n isPartner: true,\n partnerPromotionKey: \"doubaoseed\",\n icon: \"doubao\",\n iconColor: \"#3370FF\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"CCSub\",\n websiteUrl: \"https://www.ccsub.net\",\n apiKeyUrl: \"https://www.ccsub.net/register?ref=Y6Z8DXEA\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"CCSub\",\n options: {\n baseURL: \"https://www.ccsub.net/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"gpt-5.5\": { name: \"GPT-5.5\" },\n },\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"ccsub\",\n icon: \"ccsub\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"SubRouter\",\n websiteUrl: \"https://subrouter.ai\",\n apiKeyUrl: \"https://subrouter.ai/register?aff=l3ri\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"SubRouter\",\n options: {\n baseURL: \"https://subrouter.ai/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"gpt-5.5\": { name: \"GPT-5.5\" },\n },\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"subrouter\",\n icon: \"subrouter\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Unity2.ai\",\n websiteUrl: \"https://unity2.ai\",\n apiKeyUrl: \"https://unity2.ai/register?source=ccs\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"Unity2.ai\",\n options: {\n baseURL: \"https://api.unity2.ai/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"gpt-5.5\": { name: \"GPT-5.5\" },\n },\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"unity2\",\n icon: \"unity2\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"DeepSeek\",\n websiteUrl: \"https://platform.deepseek.com\",\n apiKeyUrl: \"https://platform.deepseek.com/api_keys\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n options: {\n baseURL: \"https://api.deepseek.com/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"deepseek-v4-pro\": { name: \"DeepSeek V4 Pro\" },\n \"deepseek-v4-flash\": { name: \"DeepSeek V4 Flash\" },\n },\n },\n category: \"cn_official\",\n icon: \"deepseek\",\n iconColor: \"#1E88E5\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-...\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Zhipu GLM\",\n websiteUrl: \"https://open.bigmodel.cn\",\n apiKeyUrl: \"https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"Zhipu GLM\",\n options: {\n baseURL: \"https://open.bigmodel.cn/api/coding/paas/v4\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"glm-5.1\": { name: \"GLM-5.1\" },\n },\n },\n category: \"cn_official\",\n icon: \"zhipu\",\n iconColor: \"#0F62FE\",\n templateValues: {\n baseURL: {\n label: \"Base URL\",\n placeholder: \"https://open.bigmodel.cn/api/coding/paas/v4\",\n defaultValue: \"https://open.bigmodel.cn/api/coding/paas/v4\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Zhipu GLM en\",\n websiteUrl: \"https://z.ai\",\n apiKeyUrl: \"https://z.ai/subscribe?ic=8JVLJQFSKB\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"Zhipu GLM en\",\n options: {\n baseURL: \"https://api.z.ai/api/coding/paas/v4\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"glm-5.1\": { name: \"GLM-5.1\" },\n },\n },\n category: \"cn_official\",\n icon: \"zhipu\",\n iconColor: \"#0F62FE\",\n templateValues: {\n baseURL: {\n label: \"Base URL\",\n placeholder: \"https://api.z.ai/api/coding/paas/v4\",\n defaultValue: \"https://api.z.ai/api/coding/paas/v4\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Bailian\",\n websiteUrl: \"https://bailian.console.aliyun.com\",\n apiKeyUrl: \"https://bailian.console.aliyun.com/#/api-key\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"Bailian\",\n options: {\n baseURL: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {},\n },\n category: \"cn_official\",\n icon: \"bailian\",\n iconColor: \"#624AFF\",\n templateValues: {\n baseURL: {\n label: \"Base URL\",\n placeholder: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n defaultValue: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-...\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Kimi\",\n primePartner: true,\n websiteUrl: \"https://platform.kimi.com?aff=cc-switch\",\n apiKeyUrl: \"https://platform.kimi.com/console/api-keys?aff=cc-switch\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"Kimi\",\n options: {\n baseURL: \"https://api.moonshot.cn/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"kimi-k2.7-code\": { name: \"Kimi K2.7 Code\" },\n },\n },\n category: \"cn_official\",\n icon: \"kimi\",\n iconColor: \"#6366F1\",\n templateValues: {\n baseURL: {\n label: \"Base URL\",\n placeholder: \"https://api.moonshot.cn/v1\",\n defaultValue: \"https://api.moonshot.cn/v1\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-...\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Kimi For Coding\",\n primePartner: true,\n websiteUrl: \"https://www.kimi.com/code/?aff=cc-switch\",\n apiKeyUrl: \"https://platform.kimi.com/console/api-keys?aff=cc-switch\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"Kimi For Coding\",\n options: {\n baseURL: \"https://api.kimi.com/coding/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"kimi-for-coding\": { name: \"Kimi For Coding\" },\n },\n },\n category: \"cn_official\",\n icon: \"kimi\",\n iconColor: \"#6366F1\",\n templateValues: {\n baseURL: {\n label: \"Base URL\",\n placeholder: \"https://api.kimi.com/coding/v1\",\n defaultValue: \"https://api.kimi.com/coding/v1\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-...\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"StepFun\",\n websiteUrl: \"https://platform.stepfun.com/step-plan\",\n apiKeyUrl: \"https://platform.stepfun.com/interface-key\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"StepFun\",\n options: {\n baseURL: \"https://api.stepfun.com/step_plan/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"step-3.5-flash-2603\": { name: \"Step 3.5 Flash 2603\" },\n \"step-3.5-flash\": { name: \"Step 3.5 Flash\" },\n },\n },\n category: \"cn_official\",\n icon: \"stepfun\",\n iconColor: \"#16D6D2\",\n templateValues: {\n baseURL: {\n label: \"Base URL\",\n placeholder: \"https://api.stepfun.com/step_plan/v1\",\n defaultValue: \"https://api.stepfun.com/step_plan/v1\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"step-...\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"StepFun en\",\n websiteUrl: \"https://platform.stepfun.ai/step-plan\",\n apiKeyUrl: \"https://platform.stepfun.ai/interface-key\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"StepFun en\",\n options: {\n baseURL: \"https://api.stepfun.ai/step_plan/v1\",\n apiKey: \"\",\n },\n models: {\n \"step-3.5-flash-2603\": { name: \"Step 3.5 Flash 2603\" },\n \"step-3.5-flash\": { name: \"Step 3.5 Flash\" },\n },\n },\n category: \"cn_official\",\n icon: \"stepfun\",\n iconColor: \"#16D6D2\",\n templateValues: {\n baseURL: {\n label: \"Base URL\",\n placeholder: \"https://api.stepfun.ai/step_plan/v1\",\n defaultValue: \"https://api.stepfun.ai/step_plan/v1\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"step-...\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"StepFun Step Plan\",\n websiteUrl: \"https://platform.stepfun.com/docs/zh/step-plan/overview\",\n apiKeyUrl: \"https://platform.stepfun.com/interface-key\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"StepFun Step Plan\",\n options: {\n baseURL: \"https://api.stepfun.com/step_plan/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"step-3.5-flash\": { name: \"Step 3.5 Flash\" },\n },\n },\n category: \"cn_official\",\n icon: \"stepfun\",\n iconColor: \"#005AFF\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"step-...\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"ModelScope\",\n websiteUrl: \"https://modelscope.cn\",\n apiKeyUrl: \"https://modelscope.cn/my/myaccesstoken\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"ModelScope\",\n options: {\n baseURL: \"https://api-inference.modelscope.cn/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"ZhipuAI/GLM-5.1\": { name: \"GLM-5.1\" },\n },\n },\n category: \"aggregator\",\n icon: \"modelscope\",\n iconColor: \"#624AFF\",\n templateValues: {\n baseURL: {\n label: \"Base URL\",\n placeholder: \"https://api-inference.modelscope.cn/v1\",\n defaultValue: \"https://api-inference.modelscope.cn/v1\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"KAT-Coder\",\n websiteUrl: \"https://console.streamlake.ai\",\n apiKeyUrl: \"https://console.streamlake.ai/console/api-key\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"KAT-Coder\",\n options: {\n baseURL:\n \"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"KAT-Coder-Pro\": { name: \"KAT-Coder Pro\" },\n },\n },\n category: \"cn_official\",\n templateValues: {\n baseURL: {\n label: \"Base URL\",\n placeholder:\n \"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai\",\n defaultValue:\n \"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai\",\n editorValue: \"\",\n },\n ENDPOINT_ID: {\n label: \"Vanchin Endpoint ID\",\n placeholder: \"ep-xxx-xxx\",\n defaultValue: \"\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n icon: \"catcoder\",\n },\n {\n name: \"Longcat\",\n websiteUrl: \"https://longcat.chat/platform\",\n apiKeyUrl: \"https://longcat.chat/platform/api_keys\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"Longcat\",\n options: {\n baseURL: \"https://api.longcat.chat/openai/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"LongCat-2.0\": {\n name: \"LongCat 2.0\",\n options: { thinking: { type: \"disabled\" } },\n },\n },\n },\n category: \"cn_official\",\n icon: \"longcat\",\n iconColor: \"#29E154\",\n templateValues: {\n baseURL: {\n label: \"Base URL\",\n placeholder: \"https://api.longcat.chat/openai/v1\",\n defaultValue: \"https://api.longcat.chat/openai/v1\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"MiniMax\",\n websiteUrl: \"https://platform.minimaxi.com\",\n apiKeyUrl: \"https://platform.minimaxi.com/subscribe/coding-plan\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"MiniMax\",\n options: {\n baseURL: \"https://api.minimaxi.com/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"MiniMax-M2.7\": { name: \"MiniMax M2.7\" },\n },\n },\n category: \"cn_official\",\n partnerPromotionKey: \"minimax_cn\",\n theme: {\n backgroundColor: \"#f64551\",\n textColor: \"#FFFFFF\",\n },\n icon: \"minimax\",\n iconColor: \"#FF6B6B\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"MiniMax en\",\n websiteUrl: \"https://platform.minimax.io\",\n apiKeyUrl: \"https://platform.minimax.io/subscribe/coding-plan\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"MiniMax en\",\n options: {\n baseURL: \"https://api.minimax.io/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"MiniMax-M2.7\": { name: \"MiniMax M2.7\" },\n },\n },\n category: \"cn_official\",\n partnerPromotionKey: \"minimax_en\",\n theme: {\n backgroundColor: \"#f64551\",\n textColor: \"#FFFFFF\",\n },\n icon: \"minimax\",\n iconColor: \"#FF6B6B\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"BaiLing\",\n websiteUrl: \"https://alipaytbox.yuque.com/sxs0ba/ling/get_started\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"BaiLing\",\n options: {\n baseURL: \"https://api.tbox.cn/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"Ling-2.5-1T\": { name: \"Ling 2.5-1T\" },\n },\n },\n category: \"cn_official\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Xiaomi MiMo\",\n websiteUrl: \"https://platform.xiaomimimo.com\",\n apiKeyUrl: \"https://platform.xiaomimimo.com/#/console/api-keys\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"Xiaomi MiMo\",\n options: {\n baseURL: \"https://api.xiaomimimo.com/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"mimo-v2.5-pro\": {\n name: \"MiMo V2.5 Pro\",\n limit: { context: 1048576, output: 131072 },\n modalities: { input: [\"text\"], output: [\"text\"] },\n },\n \"mimo-v2.5\": {\n name: \"MiMo V2.5\",\n limit: { context: 1048576, output: 131072 },\n modalities: { input: [\"text\", \"image\"], output: [\"text\"] },\n },\n },\n },\n category: \"cn_official\",\n icon: \"xiaomimimo\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Xiaomi MiMo Token Plan (China)\",\n websiteUrl: \"https://platform.xiaomimimo.com/#/token-plan\",\n apiKeyUrl: \"https://platform.xiaomimimo.com/#/console/plan-manage\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"Xiaomi MiMo Token Plan (China)\",\n options: {\n baseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"mimo-v2.5-pro\": {\n name: \"MiMo V2.5 Pro\",\n limit: { context: 1048576, output: 131072 },\n modalities: { input: [\"text\"], output: [\"text\"] },\n },\n \"mimo-v2.5\": {\n name: \"MiMo V2.5\",\n limit: { context: 1048576, output: 131072 },\n modalities: { input: [\"text\", \"image\"], output: [\"text\"] },\n },\n },\n },\n category: \"cn_official\",\n icon: \"xiaomimimo\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"Token Plan API Key\",\n placeholder: \"tp-...\",\n editorValue: \"\",\n },\n },\n },\n\n {\n name: \"OpenCode Go\",\n websiteUrl: \"https://opencode.ai/go\",\n apiKeyUrl: \"https://opencode.ai/go?ref=2YTRG2NGTX\",\n partnerPromotionKey: \"opencode_go\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"OpenCode Go\",\n options: {\n baseURL: \"https://opencode.ai/zen/go/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"glm-5.2\": { name: \"GLM 5.2\" },\n \"kimi-k2.7-code\": { name: \"Kimi K2.7 Code\" },\n \"deepseek-v4-pro\": { name: \"DeepSeek V4 Pro\" },\n \"deepseek-v4-flash\": { name: \"DeepSeek V4 Flash\" },\n \"mimo-v2.5-pro\": { name: \"MiMo V2.5 Pro\" },\n },\n },\n category: \"third_party\",\n icon: \"opencode\",\n iconColor: \"#211E1E\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"AiHubMix\",\n websiteUrl: \"https://aihubmix.com\",\n apiKeyUrl: \"https://aihubmix.com\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"AiHubMix\",\n options: {\n baseURL: \"https://aihubmix.com/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n \"claude-opus-4-8\": { name: \"Claude Opus 4.8\" },\n },\n },\n category: \"aggregator\",\n icon: \"aihubmix\",\n iconColor: \"#006FFB\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"CherryIN\",\n websiteUrl: \"https://open.cherryin.ai\",\n apiKeyUrl: \"https://open.cherryin.ai/console/token\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"CherryIN\",\n options: {\n baseURL: \"https://open.cherryin.net/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"anthropic/claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n \"anthropic/claude-opus-4.8\": { name: \"Claude Opus 4.8\" },\n },\n },\n category: \"aggregator\",\n icon: \"cherryin\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"DMXAPI\",\n websiteUrl: \"https://www.dmxapi.cn\",\n apiKeyUrl: \"https://www.dmxapi.cn\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"DMXAPI\",\n options: {\n baseURL: \"https://www.dmxapi.cn/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n \"claude-opus-4-8\": { name: \"Claude Opus 4.8\" },\n },\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"dmxapi\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"OpenRouter\",\n websiteUrl: \"https://openrouter.ai\",\n apiKeyUrl: \"https://openrouter.ai/keys\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"OpenRouter\",\n options: {\n baseURL: \"https://openrouter.ai/api/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"anthropic/claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n \"anthropic/claude-opus-4.8\": { name: \"Claude Opus 4.8\" },\n },\n },\n category: \"aggregator\",\n icon: \"openrouter\",\n iconColor: \"#6566F1\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-or-...\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"TheRouter\",\n websiteUrl: \"https://therouter.ai\",\n apiKeyUrl: \"https://dashboard.therouter.ai\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"TheRouter\",\n options: {\n baseURL: \"https://api.therouter.ai/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"anthropic/claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n \"openai/gpt-5.3-codex\": { name: \"GPT-5.3 Codex\" },\n \"openai/gpt-5.2\": { name: \"GPT-5.2\" },\n \"google/gemini-3.5-flash\": {\n name: \"Gemini 3.5 Flash\",\n },\n \"qwen/qwen3-coder-480b\": { name: \"Qwen3 Coder 480B\" },\n },\n },\n category: \"aggregator\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-...\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Novita AI\",\n websiteUrl: \"https://novita.ai\",\n apiKeyUrl: \"https://novita.ai\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"Novita AI\",\n options: {\n baseURL: \"https://api.novita.ai/openai\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"zai-org/glm-5.1\": { name: \"GLM-5.1\" },\n },\n },\n category: \"aggregator\",\n icon: \"novita\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Nvidia\",\n websiteUrl: \"https://build.nvidia.com\",\n apiKeyUrl: \"https://build.nvidia.com/settings/api-keys\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"Nvidia\",\n options: {\n baseURL: \"https://integrate.api.nvidia.com/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"moonshotai/kimi-k2.5\": { name: \"Kimi K2.5\" },\n },\n },\n category: \"aggregator\",\n icon: \"nvidia\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"PIPELLM\",\n websiteUrl: \"https://code.pipellm.ai\",\n apiKeyUrl: \"https://code.pipellm.ai/login?ref=uvw650za\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"PIPELLM\",\n options: {\n baseURL: \"https://cc-api.pipellm.ai\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-opus-4-8\": { name: \"claude-opus-4-8\" },\n \"claude-sonnet-5\": { name: \"claude-sonnet-5\" },\n \"claude-haiku-4-5-20251001\": { name: \"claude-haiku-4-5-20251001\" },\n },\n },\n category: \"aggregator\",\n icon: \"pipellm\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"pipe-...\",\n editorValue: \"\",\n },\n },\n },\n\n {\n name: \"PackyCode\",\n websiteUrl: \"https://www.packyapi.com\",\n apiKeyUrl: \"https://www.packyapi.com/register?aff=cc-switch\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"PackyCode\",\n options: {\n baseURL: \"https://www.packyapi.com/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n \"claude-opus-4-8\": { name: \"Claude Opus 4.8\" },\n },\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"packycode\",\n icon: \"packycode\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"APIKEY.FUN\",\n websiteUrl: \"https://apikey.fun\",\n apiKeyUrl: \"https://apikey.fun/register?aff=CCSwitch\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"APIKEY.FUN\",\n options: {\n baseURL: \"https://api.apikey.fun/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-opus-4-8\": { name: \"Claude Opus 4.8\" },\n \"claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n \"claude-haiku-4-5\": { name: \"Claude Haiku 4.5\" },\n },\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"apikeyfun\",\n icon: \"apikeyfun\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"APINebula\",\n websiteUrl: \"https://apinebula.com\",\n apiKeyUrl: \"https://apinebula.com/02rw5X\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"APINebula\",\n options: {\n baseURL: \"https://apinebula.com/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"gpt-5.5\": { name: \"GPT-5.5\" },\n },\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"apinebula\",\n icon: \"apinebula\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"AtlasCloud\",\n websiteUrl: \"https://www.atlascloud.ai/console/coding-plan\",\n apiKeyUrl: \"https://www.atlascloud.ai/console/coding-plan\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"AtlasCloud\",\n options: {\n baseURL: \"https://api.atlascloud.ai/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"zai-org/glm-5.1\": { name: \"GLM 5.1\" },\n },\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"atlascloud\",\n icon: \"atlascloud\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"SudoCode\",\n websiteUrl: \"https://sudocode.chat\",\n apiKeyUrl:\n \"https://sudocode.chat/register?utm_source=ccswitch&utm_medium=partner\",\n settingsConfig: {\n npm: \"@ai-sdk/openai\",\n name: \"SudoCode\",\n options: {\n baseURL: \"https://api.sudocode.chat/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"gpt-5.6-sol\": { name: \"GPT-5.6 Sol\" },\n },\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"sudocode\",\n icon: \"sudocode\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Cubence\",\n websiteUrl: \"https://cubence.com\",\n apiKeyUrl: \"https://cubence.com/signup?code=CCSWITCH&source=ccs\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"Cubence\",\n options: {\n baseURL: \"https://api.cubence.com/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n \"claude-opus-4-8\": { name: \"Claude Opus 4.8\" },\n },\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"cubence\",\n icon: \"cubence\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"AIGoCode\",\n websiteUrl: \"https://aigocode.com\",\n apiKeyUrl: \"https://aigocode.com/invite/CC-SWITCH\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"AIGoCode\",\n options: {\n baseURL: \"https://api.aigocode.com\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n \"claude-opus-4-8\": { name: \"Claude Opus 4.8\" },\n },\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"aigocode\",\n icon: \"aigocode\",\n iconColor: \"#5B7FFF\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"RightCode\",\n websiteUrl: \"https://www.right.codes\",\n apiKeyUrl: \"https://www.right.codes/register?aff=CCSWITCH\",\n settingsConfig: {\n npm: \"@ai-sdk/openai\",\n name: \"RightCode\",\n options: {\n baseURL: \"https://right.codes/codex/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"gpt-5.5\": { name: \"GPT-5.5\" },\n },\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"rightcode\",\n icon: \"rc\",\n iconColor: \"#E96B2C\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"AICodeMirror\",\n websiteUrl: \"https://www.aicodemirror.com\",\n apiKeyUrl: \"https://www.aicodemirror.com/register?invitecode=9915W3\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"AICodeMirror\",\n options: {\n baseURL: \"https://api.aicodemirror.com/api/claudecode\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n \"claude-opus-4.8\": { name: \"Claude Opus 4.8\" },\n },\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"aicodemirror\",\n icon: \"aicodemirror\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"ClaudeCN\",\n websiteUrl: \"https://claudecn.top\",\n apiKeyUrl: \"https://claudecn.top/register?aff=ccswitch\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"ClaudeCN\",\n options: {\n baseURL: \"https://claudecn.top\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n \"claude-opus-4-8\": { name: \"Claude Opus 4.8\" },\n \"claude-haiku-4-5\": { name: \"Claude Haiku 4.5\" },\n },\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"claudecn\",\n icon: \"claudecn\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"RunAPI\",\n websiteUrl: \"https://runapi.co\",\n apiKeyUrl: \"https://runapi.co\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"RunAPI\",\n options: {\n baseURL: \"https://runapi.co\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n \"claude-opus-4-8\": { name: \"Claude Opus 4.8\" },\n \"claude-haiku-4-5\": { name: \"Claude Haiku 4.5\" },\n },\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"runapi\",\n icon: \"runapi\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"CrazyRouter\",\n websiteUrl: \"https://www.crazyrouter.com\",\n apiKeyUrl: \"https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"CrazyRouter\",\n options: {\n baseURL: \"https://cn.crazyrouter.com\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n \"claude-opus-4-8\": { name: \"Claude Opus 4.8\" },\n },\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"crazyrouter\",\n icon: \"crazyrouter\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"SSSAiCode\",\n websiteUrl: \"https://sssaicodeapi.com\",\n apiKeyUrl: \"https://sssaicodeapi.com/register?ref=DCP0SM\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"SSSAiCode\",\n options: {\n baseURL: \"https://node-hk.sssaicodeapi.com/api/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n \"claude-opus-4-8\": { name: \"Claude Opus 4.8\" },\n },\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"sssaicode\",\n icon: \"sssaicode\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Micu\",\n websiteUrl: \"https://www.micuapi.ai\",\n apiKeyUrl: \"https://www.micuapi.ai/register?aff=aOYQ\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"Micu\",\n options: {\n baseURL: \"https://www.micuapi.ai/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-opus-4-8\": { name: \"Claude Opus 4.8\" },\n \"claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n },\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"micu\",\n icon: \"micu\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"ETok.ai\",\n websiteUrl: \"https://etok.ai\",\n apiKeyUrl: \"https://etok.ai\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"ETok\",\n options: {\n baseURL: \"https://api.etok.ai/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-opus-4-8\": { name: \"Claude Opus 4.8\" },\n \"claude-sonnet-5\": { name: \"Claude Sonnet 5\" },\n },\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"etok\",\n icon: \"etok\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"E-FlowCode\",\n websiteUrl: \"https://e-flowcode.cc\",\n apiKeyUrl: \"https://e-flowcode.cc\",\n settingsConfig: {\n npm: \"@ai-sdk/openai\",\n options: {\n apiKey: \"\",\n baseURL: \"https://e-flowcode.cc/v1\",\n },\n models: {\n \"gpt-5.2-codex\": {\n name: \"gpt-5.2-codex\",\n },\n \"gpt-5.3-codex\": {\n name: \"gpt-5.3-codex\",\n },\n },\n },\n category: \"third_party\",\n icon: \"eflowcode\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-...\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"AWS Bedrock\",\n websiteUrl: \"https://aws.amazon.com/bedrock/\",\n settingsConfig: {\n npm: \"@ai-sdk/amazon-bedrock\",\n name: \"AWS Bedrock\",\n options: {\n region: \"${region}\",\n accessKeyId: \"${accessKeyId}\",\n secretAccessKey: \"${secretAccessKey}\",\n setCacheKey: true,\n },\n models: {\n \"global.anthropic.claude-opus-4-8\": { name: \"Claude Opus 4.8\" },\n \"global.anthropic.claude-sonnet-5\": {\n name: \"Claude Sonnet 5\",\n },\n \"global.anthropic.claude-haiku-4-5-20251001-v1:0\": {\n name: \"Claude Haiku 4.5\",\n },\n \"us.amazon.nova-pro-v1:0\": { name: \"Amazon Nova Pro\" },\n \"us.meta.llama4-maverick-17b-instruct-v1:0\": {\n name: \"Meta Llama 4 Maverick\",\n },\n \"us.deepseek.r1-v1:0\": { name: \"DeepSeek R1\" },\n },\n },\n category: \"cloud_provider\",\n icon: \"aws\",\n iconColor: \"#FF9900\",\n templateValues: {\n region: {\n label: \"AWS Region\",\n placeholder: \"us-west-2\",\n defaultValue: \"us-west-2\",\n editorValue: \"us-west-2\",\n },\n accessKeyId: {\n label: \"Access Key ID\",\n placeholder: \"AKIA...\",\n editorValue: \"\",\n },\n secretAccessKey: {\n label: \"Secret Access Key\",\n placeholder: \"your-secret-key\",\n editorValue: \"\",\n },\n },\n },\n {\n name: \"Oh My OpenCode\",\n websiteUrl: \"https://github.com/code-yeongyu/oh-my-openagent\",\n settingsConfig: {\n npm: \"\",\n options: {},\n models: {},\n },\n category: \"omo\" as ProviderCategory,\n icon: \"opencode\",\n iconColor: \"#8B5CF6\",\n isCustomTemplate: true,\n },\n {\n name: \"Oh My OpenCode Slim\",\n websiteUrl: \"https://github.com/alvinunreal/oh-my-opencode-slim\",\n settingsConfig: {\n npm: \"\",\n options: {},\n models: {},\n },\n category: \"omo-slim\" as ProviderCategory,\n icon: \"opencode\",\n iconColor: \"#6366F1\",\n isCustomTemplate: true,\n },\n];\n"}}, "docs/user-manual/ja/2-providers/2.1-add.md": {"1e398a3709f03db3fe5d989827090386b9dcd3be": {"t": 1785596042.4746275, "n": 20727, "h": "adfc67429d1bcca1", "c": "# 2.1 プロバイダーの追加\n\n## 追加パネルを開く\n\nメイン画面右上の **+** ボタンをクリックして、プロバイダー追加パネルを開きます。\n\nパネルは 2 つのタブに分かれています:\n- **アプリ専用プロバイダー**:現在選択中のアプリ(Claude Code / Claude Desktop / Codex / Gemini / OpenCode / OpenClaw / Hermes)専用\n- **統一プロバイダー**:アプリ間で共有する設定\n\n## プリセットで追加\n\nプリセットは事前に設定されたプロバイダーテンプレートで、API Key を入力するだけで使用できます。\n\n### 操作手順\n\n1. 「プリセット」ドロップダウンからプロバイダーを選択\n2. 名前とエンドポイントが自動入力される\n3. **API Key** を入力\n4. (任意)メモを入力\n5. 「追加」をクリック\n\n### 主なプリセット\n\n#### Claude プリセット\n\n| プリセット名 | 説明 |\n|----------|------|\n| Claude 公式 | Anthropic 公式アカウントでログイン |\n| DeepSeek | DeepSeek モデル |\n| 智谱 GLM | 智谱 AI の GLM モデル |\n| 智谱 GLM en | 智谱 AI(英語版) |\n| 百炼 | アリクラウド百炼(通义千問) |\n| Kimi | Moonshot Kimi モデル |\n| Kimi For Coding | Kimi プログラミング専用モデル |\n| StepFun | StepFun モデル |\n| ModelScope | 魔搭コミュニティ |\n| KAT-Coder | KAT-Coder モデル |\n| Longcat | Longcat AI |\n| MiniMax | MiniMax モデル |\n| MiniMax en | MiniMax(英語版) |\n| DouBaoSeed | 豆包 Seed モデル |\n| BaiLing | 百灵 AI |\n| AiHubMix | AiHubMix 統合サービス |\n| SiliconFlow | SiliconFlow |\n| SiliconFlow en | SiliconFlow(英語版) |\n| DMXAPI | DMXAPI 中継サービス |\n| PackyCode | PackyCode 中継サービス ⭐ |\n| Cubence | Cubence サービス |\n| AIGoCode | AIGoCode サービス |\n| RightCode | RightCode サービス |\n| AICodeMirror | AICodeMirror サービス |\n| OpenRouter | 統合ルーティングサービス |\n| Nvidia | Nvidia AI サービス |\n| Xiaomi MiMo | Xiaomi MiMo モデル |\n\n> ⭐ は公式パートナーを示します。プリセットリストはバージョンの更新に伴い変更される場合があります。アプリ内の実際の表示を基準にしてください。\n\n#### Claude Desktop プリセット\n\nClaude Desktop パネルには、Claude Code のプリセットカタログから変換されたプロバイダープリセットが含まれます。追加時には次のモードを選択できます:\n\n- **直結モード**:プロバイダーがネイティブの Anthropic Messages API を提供し、モデル名が Claude Desktop の認識できる三つの役割 ID(`claude-sonnet-*` / `claude-opus-*` / `claude-haiku-*`)で、Claude Desktop から直接アクセスできる場合\n- **モデルマッピングモード**:三つの役割 ID 以外のモデル名(旧式 Claude ID や DeepSeek / Kimi などの非 Claude モデル)を CC Switch ローカルゲートウェイ経由で Sonnet / Opus / Haiku ルートへマッピング\n- **Claude Desktop Official**:Claude Desktop の公式サインインモードへ戻す\n\n詳しい手順は [2.6 Claude Desktop](./2.6-claude-desktop.md) を参照してください。\n\n#### Codex プリセット\n\nCodex プリセットは上流プロトコルにより 2 種類に分かれます。\n\n**ネイティブ Responses プロトコル**(直接接続または標準プロキシ転送):\n\n| プリセット名 | 説明 |\n|----------|------|\n| OpenAI 公式 | OpenAI 公式アカウントでログイン |\n| Azure OpenAI | Azure OpenAI サービス |\n| AiHubMix | AiHubMix 統合サービス |\n| DMXAPI | DMXAPI 中継サービス |\n| PackyCode | PackyCode 中継サービス |\n| OpenRouter | 統合ルーティングサービス |\n| Cubence / AIGoCode / RightCode / AICodeMirror など | 各種中継サービス |\n\n**Chat Completions プロトコル**(「ローカルルーティングが必要」トグルが必要。プロキシが自動変換):\n\n| プリセット名 | 説明 |\n|----------|------|\n| DeepSeek | DeepSeek モデル |\n| Zhipu GLM / GLM en | Zhipu AI の GLM モデル |\n| Kimi / Kimi For Coding | Moonshot Kimi モデル |\n| MiniMax / MiniMax en | MiniMax モデル |\n| StepFun / StepFun en | StepFun Step モデル |\n| Baidu Qianfan Coding Plan | 百度千帆コーディングプラン |\n| Bailian | Alibaba Cloud 百錬(Qwen) |\n| ModelScope | ModelScope コミュニティ |\n| Longcat | Longcat AI |\n| BaiLing | BaiLing AI |\n| Xiaomi MiMo / MiMo Token Plan | Xiaomi MiMo モデル |\n| Volcengine Agentplan | Volcengine Agent Plan |\n| BytePlus | BytePlus サービス |\n| DouBaoSeed | DouBao Seed モデル |\n| SiliconFlow / SiliconFlow en | SiliconFlow |\n| Novita AI | Novita AI サービス |\n| Nvidia | Nvidia AI サービス |\n\n> 💡 Chat Completions 系プリセットを選択すると、「ローカルルーティングが必要」トグルとモデルマッピング表が自動的に設定されます。詳細は下記の「Codex ローカルルーティングとモデルマッピング」セクションを参照してください。プリセット一覧は継続的に更新されます。アプリ内の表示を正としてください。\n\n#### Gemini プリセット\n\n| プリセット名 | 説明 |\n|----------|------|\n| Google 公式 | Google OAuth でログイン |\n| PackyCode | PackyCode 中継サービス |\n| Cubence | Cubence サービス |\n| AIGoCode | AIGoCode サービス |\n| AICodeMirror | AICodeMirror サービス |\n| OpenRouter | 統合ルーティングサービス |\n| カスタム | すべてのパラメータを手動設定 |\n\n#### OpenCode プリセット\n\n| プリセット名 | 説明 |\n|----------|------|\n| DeepSeek | DeepSeek モデル |\n| 智谱 GLM | 智谱 AI の GLM モデル |\n| 智谱 GLM en | 智谱 AI(英語版) |\n| 百炼 | アリクラウド百炼 |\n| Kimi k2.5 | Moonshot Kimi-k2.5 モデル |\n| Kimi For Coding | Kimi プログラミング専用モデル |\n| StepFun | StepFun モデル |\n| ModelScope | 魔搭コミュニティ |\n| KAT-Coder | KAT-Coder モデル |\n| Longcat | Longcat AI |\n| MiniMax | MiniMax モデル |\n| MiniMax en | MiniMax(英語版) |\n| DouBaoSeed | 豆包 Seed モデル |\n| BaiLing | 百灵 AI |\n| Xiaomi MiMo | Xiaomi MiMo モデル |\n| AiHubMix | AiHubMix 統合サービス |\n| DMXAPI | DMXAPI 中継サービス |\n| OpenRouter | 統合ルーティングサービス |\n| Nvidia | Nvidia AI サービス |\n| PackyCode | PackyCode 中継サービス |\n| Cubence | Cubence サービス |\n| AIGoCode | AIGoCode サービス |\n| RightCode | RightCode サービス |\n| AICodeMirror | AICodeMirror サービス |\n| OpenAI Compatible | OpenAI 互換インターフェース |\n| Oh My OpenCode | Oh My OpenCode サービス |\n\n> プリセットリストは継続的に更新されています。アプリ内の実際の表示を基準にしてください。\n\n#### OpenClaw プリセット\n\n| プリセット名 | 説明 |\n|----------|------|\n| DeepSeek | DeepSeek モデル |\n| 智谱 GLM | 智谱 AI の GLM モデル |\n| 智谱 GLM en | 智谱 AI(英語版) |\n| Qwen Coder | 通义千問コーディングモデル |\n| Kimi k2.5 | Moonshot Kimi-k2.5 モデル |\n| Kimi For Coding | Kimi プログラミング専用モデル |\n| StepFun | StepFun モデル |\n| MiniMax | MiniMax モデル |\n| MiniMax en | MiniMax(英語版) |\n| KAT-Coder | KAT-Coder モデル |\n| Longcat | Longcat AI |\n| DouBaoSeed | 豆包 Seed モデル |\n| BaiLing | 百灵 AI |\n| Xiaomi MiMo | Xiaomi MiMo モデル |\n| AiHubMix | AiHubMix 統合サービス |\n| DMXAPI | DMXAPI 中継サービス |\n| OpenRouter | 統合ルーティングサービス |\n| ModelScope | 魔搭コミュニティ |\n| SiliconFlow | SiliconFlow |\n| SiliconFlow en | SiliconFlow(英語版) |\n| Nvidia | Nvidia AI サービス |\n| PackyCode | PackyCode 中継サービス |\n| Cubence | Cubence サービス |\n| AIGoCode | AIGoCode サービス |\n| RightCode | RightCode サービス |\n| AICodeMirror | AICodeMirror サービス |\n| AICoding | AICoding サービス |\n| CrazyRouter | CrazyRouter サービス |\n| SSSAiCode | SSSAiCode サービス |\n| AWS Bedrock | AWS Bedrock サービス |\n| OpenAI Compatible | OpenAI 互換インターフェース |\n\n## モデル自動取得\n\nプロバイダーの追加や編集時に、プロバイダーのエンドポイントから利用可能なモデルを自動検出でき、モデル ID の手動コピー&ペーストの手間を省けます。\n\n1. **API Key** と **エンドポイントアドレス** が入力されていることを確認\n2. モデル入力フィールドの横にある **モデル取得** ボタン(ダウンロードアイコン)をクリック\n3. CC Switch が設定された API Key で OpenAI 互換の `/v1/models` エンドポイントを呼び出し\n4. カテゴリ別にグループ化されたドロップダウンからモデルを選択\n\nこの機能は **Claude Code / Claude Desktop / Codex / Gemini / OpenCode / OpenClaw / Hermes** のうちモデル項目を持つプロバイダーフォームで利用でき、`/v1/models` エンドポイントをサポートするプロバイダーに対応します。Codex OAuth 系プロバイダーでは、必要に応じて ChatGPT Codex バックエンドからライブモデル一覧を取得します。\n\n**よくあるエラー:**\n- **認証失敗(401/403)**:API Key が正しいか確認してください\n- **エンドポイント未対応(404/405)**:プロバイダーが `/v1/models` エンドポイントを公開していません。手動でモデル ID を入力してください\n- **解析失敗**:レスポンスが OpenAI 互換フォーマットに準拠していません\n- **タイムアウト**:エンドポイントの応答が遅いです。後ほど再試行するかネットワークを確認してください\n\n## カスタム設定\n\n「カスタム」プリセットを選択した場合、JSON 設定を手動で編集する必要があります。\n\n### Claude 設定形式\n\n```json\n{\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"your-api-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\"\n }\n}\n```\n\n| フィールド | 必須 | 説明 |\n|------|------|------|\n| `ANTHROPIC_API_KEY` | はい | API キー |\n| `ANTHROPIC_BASE_URL` | いいえ | カスタムエンドポイントアドレス |\n| `ANTHROPIC_AUTH_TOKEN` | いいえ | API_KEY の代替認証方式 |\n\n### Codex 設定形式\n\nCodex は 2 つの設定ファイルを使用します:\n\n**1. auth.json**(`~/.codex/auth.json`)- API キーを保存:\n\n```json\n{\n \"OPENAI_API_KEY\": \"your-api-key\"\n}\n```\n\n**2. config.toml**(`~/.codex/config.toml`)- モデルとエンドポイントの設定を保存:\n\n```toml\n# 基本設定\nmodel_provider = \"custom\"\nmodel = \"gpt-5.2\"\nmodel_reasoning_effort = \"high\"\ndisable_response_storage = true\n\n# カスタムプロバイダー設定\n[model_providers.custom]\nname = \"custom\"\nbase_url = \"https://api.example.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n```\n\n**auth.json フィールド説明**:\n\n| フィールド | 必須 | 説明 |\n|------|------|------|\n| `OPENAI_API_KEY` | はい | API キー |\n\n**config.toml フィールド説明**:\n\n| フィールド | 必須 | 説明 |\n|------|------|------|\n| `model_provider` | はい | モデルプロバイダー名(`[model_providers.xxx]` と一致する必要あり) |\n| `model` | はい | 使用するモデル(例:`gpt-5.2`、`gpt-4o`) |\n| `model_reasoning_effort` | いいえ | 推論強度:`low` / `medium` / `high` |\n| `disable_response_storage` | いいえ | レスポンス保存を無効にするかどうか |\n| `goals` | いいえ | `[features]` 配下で `true` にすると Codex の `/goal` モードを有効化します。必要なプロバイダーでは、プロバイダー編集画面のスイッチで切り替えられます |\n| `base_url` | はい | API エンドポイントアドレス |\n| `wire_api` | いいえ | API プロトコルタイプ(通常 `responses`) |\n| `requires_openai_auth` | いいえ | OpenAI 認証方式を使用するかどうか |\n\nAPI Key またはカスタムエンドポイントのプロバイダーへ切り替えた後、Codex が `/goal` を認識しているのに目標を設定できない場合は、プロバイダー編集画面の **Goal mode** スイッチを有効にしてください。CC Switch はそのプロバイダーの `~/.codex/config.toml` の `[features]` に `goals = true` を書き込みます。その後、Codex を再起動してください。Goal mode 有効化後に想定より頻繁に承認を求められる場合は、Codex の設定画面を再読み込みし、`approval_policy` と `sandbox_mode` が意図した権限と一致しているか確認してください。\n\n\n### Gemini 設定形式\n\n```json\n{\n \"env\": {\n \"GEMINI_API_KEY\": \"your-api-key\",\n \"GOOGLE_GEMINI_BASE_URL\": \"https://api.example.com\"\n }\n}\n```\n\n| フィールド | 必須 | 説明 |\n|------|------|------|\n| `GEMINI_API_KEY` | はい | API キー |\n| `GOOGLE_GEMINI_BASE_URL` | いいえ | カスタムエンドポイントアドレス |\n| `GEMINI_MODEL` | いいえ | モデルの指定 |\n\n> 認証タイプは CC Switch が自動的に検出します(PackyCode API プロキシ / Google OAuth / 汎用 API Key)。手動設定は不要です。\n\n## 統一プロバイダー\n\n統一プロバイダーは Claude Code / Codex / Gemini 間で設定を共有でき、複数の API 形式をサポートする中継サービスに適しています。\n\n### 統一プロバイダーの作成\n\n1. 「統一プロバイダー」タブに切り替え\n2. 「統一プロバイダーを追加」をクリック\n3. 共通設定を入力:\n - 名前\n - API Key\n - エンドポイントアドレス\n4. 同期するアプリにチェック(Claude Code / Codex / Gemini)\n5. 保存\n\n### 同期の仕組み\n\n統一プロバイダーはチェックしたアプリに自動的に同期されます:\n\n- 統一プロバイダーを変更すると、関連するすべてのアプリの設定が同期更新される\n- 統一プロバイダーを削除すると、関連するアプリの設定も削除される\n\n### 保存して同期\n\n統一プロバイダーの編集時に選択できます:\n\n| 操作 | 説明 |\n|------|------|\n| 保存 | 設定のみ保存、すぐに同期しない |\n| 保存して同期 | 設定を保存し、有効なすべてのアプリに即座に同期 |\n\n### 手動同期\n\n手動で同期をトリガーする場合:\n\n1. 統一プロバイダーカードの「同期」ボタンをクリック\n2. 同期操作を確認\n3. 各アプリの関連プロバイダーの設定が上書きされる\n\n## プロバイダーのインポート\n\nCC Switch は 2 つの方法でプロバイダー設定をインポートできます:\n\n### 方法 1:ディープリンクでインポート\n\n`ccswitch://` プロトコルリンクでワンクリックインポート:\n\n1. ディープリンクをクリックまたはアクセス\n2. CC Switch が自動的に開き、インポート確認を表示\n3. 設定情報をプレビュー\n4. 「インポートを確認」をクリック\n\n**ディープリンクの取得方法**:\n- 他の人からの共有で取得\n- [オンライン生成ツール](https://farion1231.github.io/cc-switch/deplink.html) で作成\n\n### 方法 2:データベースバックアップからインポート\n\nSQL バックアップファイルから一括インポート:\n\n1. 「設定 → 詳細 → データ管理」を開く\n2. 「ファイルを選択」をクリック\n3. 以前にエクスポートした `.sql` バックアップファイルを選択\n4. 「インポート」をクリック\n5. 既存の設定の上書きを確認\n\n**インポート内容**:\n- すべてのプロバイダー設定\n- MCP サーバー設定\n- Prompts プリセット\n- 使用量ログ\n\n> **注意**:インポートは既存のデータベースを上書きするため、事前に現在の設定をエクスポートしてバックアップすることをお勧めします。エクスポートファイル名の形式は `cc-switch-export-{タイムスタンプ}.sql` です。\n\n## Codex OAuth リバースプロキシ(Claude プロバイダー)\n\nv3.13.0 より、CC Switch は **Codex OAuth リバースプロキシ** 経路を追加しました。**ChatGPT アカウント** を使って Claude Code 内から Codex サービスを再利用できます。\n\n> **位置ヒント**:この機能は **新しい Claude プロバイダーカードタイプ** として表示され、Codex 側のプリセットではありません。追加後は通常の API Key プロバイダーと並んで Claude のプロバイダーリストに表示されます。\n\n### 前提条件\n\n- ログイン可能な **ChatGPT アカウント**\n- `auth.openai.com` および `chatgpt.com` にアクセスできる\n- **利用前に必ず本節末尾の [⚠️ リスク通知](#️-リスク通知重要) をお読みください**\n\n### 2 つの入口\n\n以下のどちらの入口からでも開始できます:\n\n#### 入口 A:プロバイダー追加パネルから(新規ユーザー推奨)\n\n1. **Claude** アプリに切り替える\n2. 右上の **+** ボタンをクリックしてプロバイダー追加パネルを開く\n3. プリセットリストの第三者カテゴリから **Codex (ChatGPT Plus/Pro)** プリセットを選択(UI に表示される名称を優先)\n4. まだ ChatGPT アカウントにログインしていない場合、パネルが **自動的に** ログインフローへ誘導します(下記「ログインフロー」を参照)\n5. ログイン成功後、プロバイダーフォームにログイン済みアカウントが表示されるので「保存」をクリックして完了\n\n#### 入口 B:OAuth 認証センターから(マルチアカウント管理に適する)\n\n1. **設定 → OAuth 認証センター** を開く(タブに **Beta** マーク)\n2. **ChatGPT (Codex OAuth)** セクションで **ChatGPT でログイン** ボタンをクリック\n3. ログインフローを完了(下記参照)\n4. ログイン完了後、**Claude** アプリに戻る → **プロバイダーの追加** → 同じ Codex (ChatGPT Plus/Pro) プリセットを選択\n5. フォーム内の「アカウント選択」ドロップダウンから、先ほどログインしたアカウントを選択して保存\n\n### ログインフロー(Device Code)\n\nどちらの入口から入っても、ログインフローは同一です:\n\n1. **認証コードを取得**:CC Switch が OpenAI Device Code フローを呼び出し、以下を表示:\n - **認証コード**(約 8 文字、例:`ABCD-1234`)\n - 認証コード右側の **コピー** ボタン\n - その下の認証 URL `https://auth.openai.com/codex/device`\n - 「認証を待っています...」のアニメーション表示\n2. **ブラウザ認証**:リンクをクリック(または URL を手動で訪問)し、ブラウザで:\n - ChatGPT アカウントにログイン\n - 先ほどコピーした認証コードを入力\n - 認証を確認\n3. **自動ポーリング完了**:CC Switch はバックグラウンドで OpenAI サーバーをポーリングし、認証成功を検知すると待機画面を自動的に閉じます\n4. **ログイン済みアカウントを表示**:ログインした ChatGPT アカウント(ログインメール)が **OAuth 認証センター → ログイン済みアカウント** リストに表示されます\n\n> ⏱️ **認証コードの有効期限は約 15 分** です。タイムアウトすると「Device Code の有効期限切れ」が表示されるので、**再試行** をクリックして新しい認証コードを取得してください。\n\n### 有効化と使用\n\nCodex OAuth プロバイダーを追加・保存した後:\n\n1. Claude のプロバイダーリストから探す\n2. カードの **有効化** ボタンをクリック —— 通常のプロバイダーと同じ\n3. Claude Code CLI がリバースプロキシ経由で Codex サービスを使用します\n4. トレイメニューの **Claude** サブメニューにもこのプロバイダーが表示され、素早く切り替え可能\n\n> **内部動作**:CC Switch はリクエストを `https://chatgpt.com/backend-api/codex` にルーティングし、base URL が強制的に書き換えられます —— フォームにエンドポイントを手動入力する **必要はありません**。API フォーマットは `openai_responses` に固定されます。\n\n### デフォルトモデル\n\nCodex OAuth プリセットのデフォルトモデルマッピング:\n\n| 役割 | デフォルトモデル |\n| ------------- | ---------------- |\n| メインモデル | `gpt-5.4` |\n| Sonnet 役割 | `gpt-5.4` |\n| Opus 役割 | `gpt-5.4` |\n| Haiku 役割 | `gpt-5.4-mini` |\n\nv3.15.0 以降、Codex OAuth のモデル選択は固定リストだけに依存しません。モデルセレクターを開くと、CC Switch は必要に応じて ChatGPT Codex バックエンドから利用可能モデルを取得します。デフォルトマッピングは引き続き上書きできます。\n\nプロバイダーの JSON エディタで `ANTHROPIC_MODEL` などの環境変数を上書きしてカスタマイズできます。\n\n### マルチアカウント管理(OAuth 認証センター)\n\n**OAuth 認証センター** は複数の ChatGPT アカウントの同時管理をサポートします:\n\n| 操作 | 説明 |\n| -------------------------- | ---------------------------------------------------------------------- |\n| 別のアカウントを追加 | 「別のアカウントを追加」をクリックしてログインフローを繰り返す |\n| デフォルトに設定 | アカウント行の「デフォルトに設定」をクリック —— 新規プロバイダーに適用 |\n| プロバイダー用に選択 | プロバイダーフォームの「アカウント選択」ドロップダウンで特定アカウントを指定 |\n| アカウントを削除 | アカウント右側の赤い × をクリックして削除(Token がクリアされる) |\n| すべてのアカウントをログアウト | 下部の「すべてのアカウントをログアウト」ボタンで一括クリア |\n\n> **使用シーン**:チームで開発マシンを共有する場合、各メンバーの ChatGPT アカウントごとに 1 つのプロバイダーを作成し、トレイメニューから素早く切り替えできます。\n\n### Token 自動更新\n\n- Token は **有効期限の 60 秒前** に自動更新され、すべてバックグラウンドで処理されるため手動介入は不要です\n- Refresh Token はローカルデータディレクトリに保存され、どこにもアップロードされません\n- Token のエクスポートは **サポートされていません**(漏洩防止)\n\n### クォータ表示\n\nログインしてプロバイダーを有効化すると、**プロバイダーカード下部** に自動的にアカウントクォータが表示されます:\n\n| 表示要素 | 例 | カラールール |\n| ---------------- | ------------------- | --------------------------------------------- |\n| 使用率 | `45%` | < 70% 緑、70–89% オレンジ、≥ 90% 赤 |\n| リセットまでの時間 | `7d12h 後にリセット` | ChatGPT アカウントのスライディングウィンドウまたは日次制限 |\n| 更新ボタン | 円形の矢印 | 手動でクォータを再取得 |\n\n> ⚠️ **セッション期限切れ**:Token が完全に無効になった(自動更新できない)場合、カード下部に黄色い警告枠「セッション期限切れ」が表示されます。**OAuth 認証センター** からこのアカウントを削除し、再ログインしてください。\n\n### よくある失敗\n\n| シナリオ | 表示 | 解決方法 |\n| --------------------------- | --------------------------------- | --------------------------------------------- |\n| 認証コードタイムアウト | 「Device Code の有効期限切れ」 | 「再試行」をクリックして新しい認証コードを取得 |\n| ブラウザで認証拒否 | 「ユーザーが認証を拒否」 | 再ログインしブラウザで「認証」をクリック |\n| ネットワークエラー | 具体的なエラー情報を表示 | ネットワーク接続を確認、OpenAI ドメインへのアクセス可否を確認 |\n| ログイン前にプロバイダー作成 | 「ChatGPT アカウントにログインしてください」 | 先に OAuth 認証センターでログインを完了 |\n| Token 更新失敗 | クォータ欄に「セッション期限切れ」 | アカウントを削除して再ログイン |\n| クォータ取得失敗 | クォータ欄に「取得失敗」 | 「更新」ボタンをクリックして再試行 |\n\n### ⚠️ リスク通知(重要)\n\nCodex OAuth リバースプロキシは **リバースエンジニアリングされた OAuth フロー** で ChatGPT アカウントの Codex サービスにアクセスします。有効化前に必ず以下のリスクをご理解ください:\n\n1. **利用規約違反**:OpenAI の利用規約に違反する可能性があります。同規約は未承認の自動化アクセス、サービスの複製、および既定のアクセス経路の迂回を禁止しています\n2. **アカウントリスク**:OpenAI は異常な使用パターンを疑わしい自動化として検知し、ChatGPT アカウントに一時的または永続的な制限を課す可能性があります\n3. **長期的な可用性は保証されません**:OpenAI は認証および検出メカニズムをいつでも更新する可能性があり、現在利用可能な方法が将来ブロックされる可能性があります\n\n**この機能を有効化することは、すべてのリスクを自己責任で負うことを意味します**。CC Switch は本機能の使用による一切のアカウント制限、警告、サービス停止について責任を負いません。\n\n> 📖 完全な免責事項と背景は [v3.13.0 Release Notes](../../../release-notes/v3.13.0-ja.md#️-リスク通知) をご覧ください。\n\n## 高度なオプション\n\n### API フォーマット(Claude のみ)\n\nサードパーティ API を使用する Claude プロバイダーを追加する際、高度なオプションセクションで正しい **API フォーマット** を選択する必要がある場合があります:\n\n| フォーマット | 説明 | 使用場面 |\n|------|------|------|\n| **Anthropic Messages** | ネイティブ Anthropic API フォーマット(デフォルト) | Anthropic API に直接接続、または互換プロキシ |\n| **OpenAI Chat Completions** | OpenAI Chat API フォーマット、プロキシが自動変換 | プロバイダーが OpenAI Chat フォーマットのみ対応 |\n| **OpenAI Responses API** | OpenAI Responses API フォーマット、プロキシが自動変換 | プロバイダーが OpenAI Responses フォーマットのみ対応 |\n\n> **注意**:API フォーマットの変換はプロキシサービスが担当します。Anthropic 以外のフォーマットを使用する場合、リクエスト/レスポンスの正しい変換のためにプロキシが接管有効の状態で稼働している必要があります。詳しくは [4.1 プロキシサービス](../4-proxy/4.1-service.md) をご覧ください。\n\nデフォルト以外の API フォーマットが設定されている場合、高度なオプションセクションが自動展開されます。\n\n### 完全URLエンドポイントモード\n\nv3.13.0 で追加された高度なオプション。デフォルトでは、CC Switch は設定された `base_url` を **プレフィックス** として扱い、`/v1/chat/completions` などの固定パスを後ろに連結します。一部のベンダー(非標準の URL レイアウトを必要とする第三者サービスなど)では、この連結方式ではリクエストが失敗します。\n\n**有効化方法**:\n\n1. プロバイダーを編集し、「高度なオプション」を展開\n2. **完全 URL モード** チェックボックスにチェックを入れる\n3. **完全なアップストリームエンドポイント**(プレフィックスではなく)を `base_url` に入力\n\n**例の比較**:\n\n| モード | `base_url` の記入例 | 実際のリクエスト先 |\n| ----------------------- | ------------------------------------------------ | ------------------------------------------------ |\n| デフォルト(プレフィックス連結) | `https://api.example.com` | `https://api.example.com/v1/chat/completions` |\n| **完全 URL モード** | `https://api.example.com/custom/path/messages` | `https://api.example.com/custom/path/messages` |\n\n**使用シーン**:\n- ベンダーが非標準パスを要求する場合(`/v1/chat/completions` 以外)\n- ベンダーに多階層のパス構造がある場合\n- ベンダー専用の API ゲートウェイパス\n\n> **ヒント**:プロキシ転送および Stream Check のいずれも「完全 URL モード」の設定に従うため、有効化後に追加調整は不要です。このオプションを無効化すると、パス連結はデフォルトの動作に戻ります。\n\n### Claude 共通設定クイックトグル\n\nClaude プロバイダーの編集時、JSON エディタの上部に **クイックトグル** が利用できます:\n\n| トグル | 効果 | 設定変更 |\n|------|------|------|\n| **帰属情報を非表示** | コミット/PR の帰属メタデータをクリア | `attribution: {commit: \"\", pr: \"\"}` を設定 |\n| **チームメイトを有効化** | エージェントチーム機能を有効化 | `env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = \"1\"` を設定 |\n| **ツール検索を有効化** | ツール検索機能を有効化 | `env.ENABLE_TOOL_SEARCH = \"true\"` を設定 |\n| **最大強度思考** | エフォートレベルを max に設定 | `env.CLAUDE_CODE_EFFORT_LEVEL = \"max\"` を設定 |\n| **自動アップグレードを無効化** | Claude Code の自動更新を防止 | `env.DISABLE_AUTOUPDATER = \"1\"` を設定 |\n\nトグルのチェックを外すと、対応する設定エントリが完全に削除されます。変更は JSON エディタにリアルタイムで反映されます。\n\nまた、**共通設定を適用** チェックボックスを有効にすると、グローバル設定スニペットをプロバイダーにマージできます。**共通設定を編集** をクリックして共有スニペットをカスタマイズできます。\n\n### Codex ローカルルーティングとモデルマッピング\n\n一部の第三者プロバイダーは **OpenAI Chat Completions** プロトコルのみをサポートするか、非 GPT モデル名(DeepSeek、Kimi、MiniMax など)を使用します。Codex はネイティブには OpenAI Responses API と GPT 系列モデルのみを認識するため、これらのプロバイダーでは CC Switch がローカルでプロトコルとモデルを変換する必要があります。\n\n#### ローカルルーティングが必要\n\nCodex プロバイダーの編集時、**ローカルルーティングが必要** トグルが利用できます:\n\n- **有効にするタイミング**:プロバイダーが Chat Completions プロトコルを使用する、またはモデル名が Codex デフォルトの GPT 系列でない場合\n- **有効時**:CC Switch のローカルプロキシが、Codex の送信する Responses リクエストを上流の Chat Completions に変換し、レスポンス(ストリーミング SSE、推論内容、ツール呼び出しを含む)を Responses 形式に再変換します\n- **前提条件**:変換を有効にするには[ローカルルーティング](../4-proxy/4.1-service.md)を起動し、Codex の引き継ぎを有効にする必要があります。使用中はローカルルーティングを起動したままにしてください\n\n> 💡 DeepSeek や Kimi などの Chat 形式プリセットを選択すると、このトグルはデフォルトで有効になっており、手動設定は不要です。\n\n#### モデルマッピング\n\n**ローカルルーティングが必要** を有効にすると、その下に **モデルマッピング** 表が表示され、このプロバイダーで利用可能なモデルを宣言できます:\n\n| フィールド | 説明 |\n|------|------|\n| モデル ID | 上流の実際のモデル名(例:`deepseek-v4-flash`) |\n| 表示名 | (任意)`/model` コマンドに表示される名称 |\n| コンテキストウィンドウ | (任意)モデルのコンテキスト長 |\n\n- マッピング表は Codex の `model_catalog_json` を生成し、`/model` コマンドでこれらの第三者モデル名を表示できるようにします\n- リストの内容はそのまま保存され、モデルリストの唯一の情報源となります\n- 変更後、モデルリストを更新するには **Codex の再起動が必要** です(`model_catalog_json` は Codex 起動時に読み込まれます)\n\n#### 思考能力(Reasoning)の自動判定\n\nローカルルーティングを有効にすると、CC Switch はプロバイダーの「思考」(reasoning)インターフェースを**自動判定**し、Codex が送る思考リクエストを上流が理解できるパラメータに変換します(**手動設定は不要**)。判定はプロバイダーの名前・エンドポイント・モデル名に基づきます:\n\n- **集約 / ホスティングプラットフォーム優先**:OpenRouter、SiliconFlow などはプラットフォーム規則で処理(同じモデルでもプラットフォームごとに思考インターフェースが全く異なる場合があるため)\n- **それ以外はモデルブランド別**:DeepSeek、Kimi / Moonshot、Zhipu GLM、Qwen、MiniMax、Xiaomi MiMo、StepFun などにそれぞれ規則あり\n\nプロバイダーの「思考」能力は 2 種類に分かれます:\n\n| 能力 | 意味 | 代表的なプロバイダー |\n|------|------|----------------------|\n| **思考レベル対応** | 思考の強度を調整可能(low / medium / high など) | DeepSeek、OpenRouter、StepFun(`step-3.5-flash-2603` のみ) |\n| **オン / オフ切替のみ** | 思考のオン / オフのみ、**レベルは無効** | Kimi、Zhipu GLM、Qwen、MiniMax、Xiaomi MiMo、SiliconFlow |\n\n> ⚠️ **一部のプロバイダーでは思考レベルが効きません**:オン / オフ切替のみのプロバイダーでは、Codex で思考レベル(`model_reasoning_effort` の low / medium / high)を変えても**効果はありません**——CC Switch はこれらの上流にレベルを送りません(API がパラメータを受け付けず、無理に送るとリクエストが拒否される可能性があるため)。これらのプロバイダーの思考は**デフォルトでオン**で、「調整」ではなく「オン / オフ」で動作します。実際にレベル変更が効くのは、思考レベルに対応したプロバイダー(DeepSeek、OpenRouter など)だけです。\n\n> 💡 **判定は名前 / エンドポイント / モデル名のキーワードマッチング**であり、実際の能力探索ではありません。公式ドメイン(`api.deepseek.com`、`api.moonshot.cn` など)は常に認識されますが、ドメインとモデル名を書き換えた中継サービスでは判定できないことがあり、その場合は思考パラメータが一切注入されません。\n\n### Codex 1M コンテキストウィンドウ\n\nCodex プロバイダーの追加時、**1M コンテキストウィンドウを有効化** トグルが利用できます:\n\n- **有効時**:config.toml に `model_context_window = 1000000` を設定し、`model_auto_compact_token_limit = 900000` を自動入力\n- **無効時**:両方のフィールドを削除\n\nトグルがオンの場合に表示されるテキストフィールドで、自動コンパクト制限をカスタマイズできます。v3.15.0 以降、このトグルは Codex プロバイダーの新規追加時のみ表示されます。既存プロバイダーの編集時は、必要に応じて高度な設定で該当フィールドを直接調整してください。\n\n### カスタムアイコン\n\n名前の左側にあるアイコンエリアをクリックすると:\n\n- プリセットアイコンを選択\n- アイコンの色をカスタマイズ\n\n### Web サイトリンク\n\nプロバイダーの公式サイトやコンソールのアドレスを入力して、素早くアクセスできます:\n\n- プロバイダーカードのリンクアイコンをクリックすると直接開く\n- 残額の確認や API Key の取得などに使用\n\n### メモ\n\n以下のようなメモ情報を追加できます:\n\n- アカウントの用途(個人/仕事)\n- プランの情報\n- 有効期限\n\nメモはプロバイダーカードに表示され、検索にも対応しています。\n\n### エンドポイント速度テスト\n\nプロバイダーを追加した後、API エンドポイントの速度テストができます:\n\n1. プロバイダーカードの「テスト」ボタンをクリック\n2. テストパネルで複数のエンドポイント URL を追加\n3. 「テスト」をクリックして実行\n4. レイテンシが最も低いエンドポイントを選択\n\n**テスト結果**:\n- 緑:レイテンシ < 500ms(優秀)\n- 黄:レイテンシ 500-1000ms(普通)\n- 赤:レイテンシ > 1000ms(遅い)\n\n![image-20260108005327817](../../assets/image-20260108005327817.png)\n"}}, "src/config/openclawProviderPresets.ts": {"1e398a3709f03db3fe5d989827090386b9dcd3be": {"t": 1785674630.9167025, "n": 65030, "h": "c482bfebe2eeafe9", "c": "/**\n * OpenClaw provider presets configuration\n * OpenClaw uses models.providers structure with custom provider configs\n */\nimport type {\n ProviderCategory,\n OpenClawProviderConfig,\n OpenClawDefaultModel,\n} from \"../types\";\nimport type { PresetTheme, TemplateValueConfig } from \"./claudeProviderPresets\";\n\n/** Suggested default model configuration for a preset */\nexport interface OpenClawSuggestedDefaults {\n /** Default model config to apply (agents.defaults.model) */\n model?: OpenClawDefaultModel;\n /** Model catalog entries to add (agents.defaults.models) */\n modelCatalog?: Record<string, { alias?: string }>;\n}\n\nexport interface OpenClawProviderPreset {\n name: string;\n nameKey?: string; // i18n key for localized display name\n websiteUrl: string;\n apiKeyUrl?: string;\n /** OpenClaw settings_config structure */\n settingsConfig: OpenClawProviderConfig;\n isOfficial?: boolean;\n isPartner?: boolean;\n primePartner?: boolean; // 置顶合作伙伴(顶级):徽章显示为心形\n partnerPromotionKey?: string;\n category?: ProviderCategory;\n /** Template variable definitions */\n templateValues?: Record<string, TemplateValueConfig>;\n /** Visual theme config */\n theme?: PresetTheme;\n /** Icon name */\n icon?: string;\n /** Icon color */\n iconColor?: string;\n /** Mark as custom template (for UI distinction) */\n isCustomTemplate?: boolean;\n /** Suggested default model configuration */\n suggestedDefaults?: OpenClawSuggestedDefaults;\n}\n\nfunction rebaseOpenClawModelRef(modelRef: string, providerKey: string): string {\n const slashIndex = modelRef.indexOf(\"/\");\n return slashIndex === -1\n ? `${providerKey}/${modelRef}`\n : `${providerKey}${modelRef.slice(slashIndex)}`;\n}\n\n/**\n * OpenClaw default model refs are stored as \"<provider-key>/<model-id>\".\n * Presets carry stable built-in keys for display/tests, but the real key is\n * chosen in the add-provider form, so rewrite refs right before submission.\n */\nexport function rebaseOpenClawSuggestedDefaults(\n defaults: OpenClawSuggestedDefaults,\n providerKey: string,\n): OpenClawSuggestedDefaults {\n const key = providerKey.trim();\n if (!key) return defaults;\n\n return {\n model: defaults.model\n ? {\n ...defaults.model,\n primary: rebaseOpenClawModelRef(defaults.model.primary, key),\n fallbacks: defaults.model.fallbacks?.map((modelRef) =>\n rebaseOpenClawModelRef(modelRef, key),\n ),\n }\n : undefined,\n modelCatalog: defaults.modelCatalog\n ? Object.fromEntries(\n Object.entries(defaults.modelCatalog).map(([modelRef, entry]) => [\n rebaseOpenClawModelRef(modelRef, key),\n entry,\n ]),\n )\n : undefined,\n };\n}\n\n/**\n * OpenClaw API protocol options\n * @see https://github.com/openclaw/openclaw/blob/main/docs/gateway/configuration.md\n */\nexport const openclawApiProtocols = [\n { value: \"openai-completions\", label: \"OpenAI Completions\" },\n { value: \"openai-responses\", label: \"OpenAI Responses\" },\n { value: \"anthropic-messages\", label: \"Anthropic Messages\" },\n { value: \"google-generative-ai\", label: \"Google Generative AI\" },\n { value: \"bedrock-converse-stream\", label: \"AWS Bedrock\" },\n] as const;\n\n/**\n * OpenClaw provider presets list\n */\nexport const openclawProviderPresets: OpenClawProviderPreset[] = [\n {\n name: \"Shengsuanyun\",\n nameKey: \"providerForm.presets.shengsuanyun\",\n websiteUrl: \"https://www.shengsuanyun.com/?from=CH_4HHXMRYF\",\n apiKeyUrl: \"https://www.shengsuanyun.com/?from=CH_4HHXMRYF\",\n settingsConfig: {\n baseUrl: \"https://router.shengsuanyun.com/api\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"anthropic/claude-opus-4.8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n {\n id: \"anthropic/claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n cost: { input: 3, output: 15 },\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"shengsuanyun\",\n icon: \"shengsuanyun\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"shengsuanyun/anthropic/claude-opus-4.8\",\n fallbacks: [\"shengsuanyun/anthropic/claude-sonnet-5\"],\n },\n modelCatalog: {\n \"shengsuanyun/anthropic/claude-opus-4.8\": { alias: \"Opus\" },\n \"shengsuanyun/anthropic/claude-sonnet-5\": { alias: \"Sonnet\" },\n },\n },\n },\n {\n name: \"火山Agentplan\",\n websiteUrl:\n \"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch\",\n apiKeyUrl:\n \"https://www.volcengine.com/activity/codingplan?ac=MMAP8JTTCAQ2&rc=6J6FV5N2&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch\",\n settingsConfig: {\n baseUrl: \"https://ark.cn-beijing.volces.com/api/coding/v3\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"ark-code-latest\",\n name: \"Ark Code Latest\",\n contextWindow: 256000,\n },\n ],\n },\n category: \"cn_official\",\n isPartner: true,\n partnerPromotionKey: \"volcengine_agentplan\",\n icon: \"huoshan\",\n iconColor: \"#3370FF\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"ark_agentplan/ark-code-latest\" },\n modelCatalog: {\n \"ark_agentplan/ark-code-latest\": { alias: \"Ark Code\" },\n },\n },\n },\n {\n name: \"BytePlus\",\n websiteUrl:\n \"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch\",\n apiKeyUrl:\n \"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch\",\n settingsConfig: {\n baseUrl: \"https://ark.ap-southeast.bytepluses.com/api/coding/v3\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"ark-code-latest\",\n name: \"Ark Code Latest\",\n contextWindow: 256000,\n },\n ],\n },\n category: \"cn_official\",\n isPartner: true,\n partnerPromotionKey: \"byteplus\",\n icon: \"byteplus\",\n iconColor: \"#3370FF\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"byteplus/ark-code-latest\" },\n modelCatalog: {\n \"byteplus/ark-code-latest\": { alias: \"Ark Code\" },\n },\n },\n },\n {\n name: \"DouBaoSeed\",\n websiteUrl:\n \"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch\",\n apiKeyUrl:\n \"https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey?apikey=%7B%7D&utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch\",\n settingsConfig: {\n baseUrl: \"https://ark.cn-beijing.volces.com/api/v3\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"doubao-seed-2-1-pro-260628\",\n name: \"DouBao Seed 2.1 Pro\",\n contextWindow: 262144,\n cost: { input: 0.84, output: 4.2 },\n },\n ],\n },\n category: \"cn_official\",\n isPartner: true,\n partnerPromotionKey: \"doubaoseed\",\n icon: \"doubao\",\n iconColor: \"#3370FF\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"doubaoseed/doubao-seed-2-1-pro-260628\" },\n modelCatalog: {\n \"doubaoseed/doubao-seed-2-1-pro-260628\": { alias: \"DouBao\" },\n },\n },\n },\n {\n name: \"CCSub\",\n websiteUrl: \"https://www.ccsub.net\",\n apiKeyUrl: \"https://www.ccsub.net/register?ref=Y6Z8DXEA\",\n settingsConfig: {\n baseUrl: \"https://www.ccsub.net/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"gpt-5.5\",\n name: \"GPT-5.5\",\n contextWindow: 400000,\n cost: { input: 5, output: 15 },\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"ccsub\",\n icon: \"ccsub\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"ccsub/gpt-5.5\",\n },\n modelCatalog: {\n \"ccsub/gpt-5.5\": { alias: \"GPT-5.5\" },\n },\n },\n },\n {\n name: \"SubRouter\",\n websiteUrl: \"https://subrouter.ai\",\n apiKeyUrl: \"https://subrouter.ai/register?aff=l3ri\",\n settingsConfig: {\n baseUrl: \"https://subrouter.ai/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"gpt-5.5\",\n name: \"GPT-5.5\",\n contextWindow: 400000,\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"subrouter\",\n icon: \"subrouter\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"subrouter/gpt-5.5\",\n },\n modelCatalog: {\n \"subrouter/gpt-5.5\": { alias: \"GPT-5.5\" },\n },\n },\n },\n {\n name: \"Qiniu\",\n nameKey: \"providerForm.presets.qiniu\",\n websiteUrl: \"https://s.qiniu.com/nMvAvy\",\n apiKeyUrl: \"https://s.qiniu.com/nMvAvy\",\n settingsConfig: {\n baseUrl: \"https://api.qnaigc.com/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"gpt-5.5\",\n name: \"GPT-5.5\",\n contextWindow: 400000,\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"qiniu\",\n icon: \"qiniu\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"qiniu/gpt-5.5\",\n },\n modelCatalog: {\n \"qiniu/gpt-5.5\": { alias: \"GPT-5.5\" },\n },\n },\n },\n {\n name: \"FennoAI\",\n websiteUrl: \"https://api.fenno.ai\",\n apiKeyUrl:\n \"https://api.fenno.ai/register?redirect=/purchase?tab=subscription%26group=16&aff=P9MR3D3PLCNL\",\n settingsConfig: {\n baseUrl: \"https://api.fenno.ai/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"gpt-5.5\",\n name: \"GPT-5.5\",\n contextWindow: 400000,\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"fenno\",\n icon: \"fenno\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"fenno/gpt-5.5\",\n },\n modelCatalog: {\n \"fenno/gpt-5.5\": { alias: \"GPT-5.5\" },\n },\n },\n },\n {\n name: \"ZetaAPI\",\n websiteUrl: \"https://zetaapi.ai\",\n apiKeyUrl: \"https://zetaapi.ai/go/ccs\",\n settingsConfig: {\n baseUrl: \"https://api.zetaapi.ai/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"gpt-5.5\",\n name: \"GPT-5.5\",\n contextWindow: 400000,\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"zetaapi\",\n icon: \"zetaapi\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"zetaapi/gpt-5.5\",\n },\n modelCatalog: {\n \"zetaapi/gpt-5.5\": { alias: \"GPT-5.5\" },\n },\n },\n },\n {\n name: \"TeamoRouter\",\n websiteUrl: \"https://teamorouter.com\",\n apiKeyUrl:\n \"https://teamorouter.com/?utm_source=cc_switch&utm_medium=referral&utm_campaign=ai_directory\",\n settingsConfig: {\n baseUrl: \"https://api.teamorouter.com/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"gpt-5.5\",\n name: \"GPT-5.5\",\n contextWindow: 400000,\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"teamorouter\",\n icon: \"teamorouter\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"teamorouter/gpt-5.5\",\n },\n modelCatalog: {\n \"teamorouter/gpt-5.5\": { alias: \"GPT-5.5\" },\n },\n },\n },\n {\n name: \"Amux\",\n websiteUrl: \"https://amux.ai\",\n apiKeyUrl: \"https://amux.ai\",\n settingsConfig: {\n baseUrl: \"https://api.amux.ai/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"gpt-5.5\",\n name: \"GPT-5.5\",\n contextWindow: 400000,\n },\n ],\n },\n category: \"aggregator\",\n icon: \"amux\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"amux/gpt-5.5\",\n },\n modelCatalog: {\n \"amux/gpt-5.5\": { alias: \"GPT-5.5\" },\n },\n },\n },\n {\n name: \"Code0\",\n websiteUrl: \"https://code0.ai\",\n apiKeyUrl: \"https://code0.ai/agent/register/B2XHxGjGmRvqgznY\",\n settingsConfig: {\n baseUrl: \"https://code0.ai/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"gpt-5.5\",\n name: \"GPT-5.5\",\n contextWindow: 400000,\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"code0\",\n icon: \"code0\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"code0/gpt-5.5\",\n },\n modelCatalog: {\n \"code0/gpt-5.5\": { alias: \"GPT-5.5\" },\n },\n },\n },\n {\n name: \"NekoCode\",\n websiteUrl: \"https://nekocode.ai\",\n apiKeyUrl: \"https://nekocode.ai?aff=CCSWITCH\",\n settingsConfig: {\n baseUrl: \"https://nekocode.ai/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"gpt-5.5\",\n name: \"GPT-5.5\",\n contextWindow: 400000,\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"nekocode\",\n icon: \"nekocode\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"nekocode/gpt-5.5\",\n },\n modelCatalog: {\n \"nekocode/gpt-5.5\": { alias: \"GPT-5.5\" },\n },\n },\n },\n {\n name: \"Unity2.ai\",\n websiteUrl: \"https://unity2.ai\",\n apiKeyUrl: \"https://unity2.ai/register?source=ccs\",\n settingsConfig: {\n baseUrl: \"https://api.unity2.ai/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"gpt-5.5\",\n name: \"GPT-5.5\",\n contextWindow: 400000,\n cost: { input: 5, output: 15 },\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"unity2\",\n icon: \"unity2\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"unity2/gpt-5.5\",\n },\n modelCatalog: {\n \"unity2/gpt-5.5\": { alias: \"GPT-5.5\" },\n },\n },\n },\n // ========== Chinese Officials ==========\n {\n name: \"DeepSeek\",\n websiteUrl: \"https://platform.deepseek.com\",\n apiKeyUrl: \"https://platform.deepseek.com/api_keys\",\n settingsConfig: {\n baseUrl: \"https://api.deepseek.com/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"deepseek-v4-pro\",\n name: \"DeepSeek V4 Pro\",\n contextWindow: 1000000,\n cost: { input: 1.68, output: 3.36 },\n },\n {\n id: \"deepseek-v4-flash\",\n name: \"DeepSeek V4 Flash\",\n contextWindow: 1000000,\n cost: { input: 0.14, output: 0.28 },\n },\n ],\n },\n category: \"cn_official\",\n icon: \"deepseek\",\n iconColor: \"#1E88E5\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-...\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"deepseek/deepseek-v4-flash\",\n fallbacks: [\"deepseek/deepseek-v4-pro\"],\n },\n modelCatalog: {\n \"deepseek/deepseek-v4-flash\": { alias: \"Flash\" },\n \"deepseek/deepseek-v4-pro\": { alias: \"Pro\" },\n },\n },\n },\n {\n name: \"Zhipu GLM\",\n websiteUrl: \"https://open.bigmodel.cn\",\n apiKeyUrl: \"https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII\",\n settingsConfig: {\n baseUrl: \"https://open.bigmodel.cn/api/coding/paas/v4\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"glm-5.1\",\n name: \"GLM-5.1\",\n contextWindow: 128000,\n cost: { input: 0.001, output: 0.001 },\n },\n ],\n },\n category: \"cn_official\",\n icon: \"zhipu\",\n iconColor: \"#0F62FE\",\n templateValues: {\n baseUrl: {\n label: \"Base URL\",\n placeholder: \"https://open.bigmodel.cn/api/coding/paas/v4\",\n defaultValue: \"https://open.bigmodel.cn/api/coding/paas/v4\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"zhipu/glm-5.1\" },\n modelCatalog: { \"zhipu/glm-5.1\": { alias: \"GLM\" } },\n },\n },\n {\n name: \"Zhipu GLM en\",\n websiteUrl: \"https://z.ai\",\n apiKeyUrl: \"https://z.ai/subscribe?ic=8JVLJQFSKB\",\n settingsConfig: {\n baseUrl: \"https://api.z.ai/api/coding/paas/v4\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"glm-5.1\",\n name: \"GLM-5.1\",\n contextWindow: 128000,\n cost: { input: 0.001, output: 0.001 },\n },\n ],\n },\n category: \"cn_official\",\n icon: \"zhipu\",\n iconColor: \"#0F62FE\",\n templateValues: {\n baseUrl: {\n label: \"Base URL\",\n placeholder: \"https://api.z.ai/api/coding/paas/v4\",\n defaultValue: \"https://api.z.ai/api/coding/paas/v4\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"zhipu-en/glm-5.1\" },\n modelCatalog: { \"zhipu-en/glm-5.1\": { alias: \"GLM\" } },\n },\n },\n {\n name: \"Qwen Coder\",\n websiteUrl: \"https://bailian.console.aliyun.com\",\n apiKeyUrl: \"https://bailian.console.aliyun.com/#/api-key\",\n settingsConfig: {\n baseUrl: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"qwen3.5-plus\",\n name: \"Qwen3.5 Plus\",\n contextWindow: 32000,\n cost: { input: 0.002, output: 0.006 },\n },\n ],\n },\n category: \"cn_official\",\n icon: \"qwen\",\n iconColor: \"#FF6A00\",\n templateValues: {\n baseUrl: {\n label: \"Base URL\",\n placeholder: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n defaultValue: \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-...\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"qwen/qwen3.5-plus\" },\n modelCatalog: { \"qwen/qwen3.5-plus\": { alias: \"Qwen\" } },\n },\n },\n {\n name: \"Kimi\",\n primePartner: true,\n websiteUrl: \"https://platform.kimi.com?aff=cc-switch\",\n apiKeyUrl: \"https://platform.kimi.com/console/api-keys?aff=cc-switch\",\n settingsConfig: {\n baseUrl: \"https://api.moonshot.cn/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"kimi-k2.7-code\",\n name: \"Kimi K2.7 Code\",\n contextWindow: 262144,\n cost: { input: 0.002, output: 0.006 },\n },\n ],\n },\n category: \"cn_official\",\n icon: \"kimi\",\n iconColor: \"#6366F1\",\n templateValues: {\n baseUrl: {\n label: \"Base URL\",\n placeholder: \"https://api.moonshot.cn/v1\",\n defaultValue: \"https://api.moonshot.cn/v1\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-...\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"kimi/kimi-k2.7-code\" },\n modelCatalog: { \"kimi/kimi-k2.7-code\": { alias: \"Kimi\" } },\n },\n },\n {\n name: \"Kimi For Coding\",\n primePartner: true,\n websiteUrl: \"https://www.kimi.com/code/?aff=cc-switch\",\n apiKeyUrl: \"https://platform.kimi.com/console/api-keys?aff=cc-switch\",\n settingsConfig: {\n baseUrl: \"https://api.kimi.com/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"kimi-for-coding\",\n name: \"Kimi For Coding\",\n contextWindow: 131072,\n cost: { input: 0.002, output: 0.006 },\n },\n ],\n },\n category: \"cn_official\",\n icon: \"kimi\",\n iconColor: \"#6366F1\",\n templateValues: {\n baseUrl: {\n label: \"Base URL\",\n placeholder: \"https://api.kimi.com/v1\",\n defaultValue: \"https://api.kimi.com/v1\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-...\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"kimi-coding/kimi-for-coding\" },\n modelCatalog: { \"kimi-coding/kimi-for-coding\": { alias: \"Kimi\" } },\n },\n },\n {\n name: \"StepFun\",\n websiteUrl: \"https://platform.stepfun.com/step-plan\",\n apiKeyUrl: \"https://platform.stepfun.com/interface-key\",\n settingsConfig: {\n baseUrl: \"https://api.stepfun.com/step_plan/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"step-3.5-flash-2603\",\n name: \"Step 3.5 Flash 2603\",\n contextWindow: 262144,\n },\n {\n id: \"step-3.5-flash\",\n name: \"Step 3.5 Flash\",\n contextWindow: 262144,\n },\n ],\n },\n category: \"cn_official\",\n icon: \"stepfun\",\n iconColor: \"#16D6D2\",\n templateValues: {\n baseUrl: {\n label: \"Base URL\",\n placeholder: \"https://api.stepfun.com/step_plan/v1\",\n defaultValue: \"https://api.stepfun.com/step_plan/v1\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"step-...\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"stepfun/step-3.5-flash-2603\" },\n modelCatalog: {\n \"stepfun/step-3.5-flash-2603\": { alias: \"StepFun\" },\n \"stepfun/step-3.5-flash\": { alias: \"StepFun Flash\" },\n },\n },\n },\n {\n name: \"StepFun en\",\n websiteUrl: \"https://platform.stepfun.ai/step-plan\",\n apiKeyUrl: \"https://platform.stepfun.ai/interface-key\",\n settingsConfig: {\n baseUrl: \"https://api.stepfun.ai/step_plan/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"step-3.5-flash-2603\",\n name: \"Step 3.5 Flash 2603\",\n contextWindow: 262144,\n },\n {\n id: \"step-3.5-flash\",\n name: \"Step 3.5 Flash\",\n contextWindow: 262144,\n },\n ],\n },\n category: \"cn_official\",\n icon: \"stepfun\",\n iconColor: \"#16D6D2\",\n templateValues: {\n baseUrl: {\n label: \"Base URL\",\n placeholder: \"https://api.stepfun.ai/step_plan/v1\",\n defaultValue: \"https://api.stepfun.ai/step_plan/v1\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"step-...\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"stepfun-en/step-3.5-flash-2603\" },\n modelCatalog: {\n \"stepfun-en/step-3.5-flash-2603\": { alias: \"StepFun\" },\n \"stepfun-en/step-3.5-flash\": { alias: \"StepFun Flash\" },\n },\n },\n },\n {\n name: \"MiniMax\",\n websiteUrl: \"https://platform.minimaxi.com\",\n apiKeyUrl: \"https://platform.minimaxi.com/subscribe/coding-plan\",\n settingsConfig: {\n baseUrl: \"https://api.minimaxi.com/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"MiniMax-M2.7\",\n name: \"MiniMax M2.7\",\n contextWindow: 200000,\n cost: { input: 0.001, output: 0.004 },\n },\n ],\n },\n category: \"cn_official\",\n partnerPromotionKey: \"minimax_cn\",\n theme: {\n backgroundColor: \"#f64551\",\n textColor: \"#FFFFFF\",\n },\n icon: \"minimax\",\n iconColor: \"#FF6B6B\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"minimax/MiniMax-M2.7\" },\n modelCatalog: { \"minimax/MiniMax-M2.7\": { alias: \"MiniMax\" } },\n },\n },\n {\n name: \"MiniMax en\",\n websiteUrl: \"https://platform.minimax.io\",\n apiKeyUrl: \"https://platform.minimax.io/subscribe/coding-plan\",\n settingsConfig: {\n baseUrl: \"https://api.minimax.io/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"MiniMax-M2.7\",\n name: \"MiniMax M2.7\",\n contextWindow: 200000,\n cost: { input: 0.001, output: 0.004 },\n },\n ],\n },\n category: \"cn_official\",\n partnerPromotionKey: \"minimax_en\",\n theme: {\n backgroundColor: \"#f64551\",\n textColor: \"#FFFFFF\",\n },\n icon: \"minimax\",\n iconColor: \"#FF6B6B\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"minimax-en/MiniMax-M2.7\" },\n modelCatalog: { \"minimax-en/MiniMax-M2.7\": { alias: \"MiniMax\" } },\n },\n },\n {\n name: \"KAT-Coder\",\n websiteUrl: \"https://console.streamlake.ai\",\n apiKeyUrl: \"https://console.streamlake.ai/console/api-key\",\n settingsConfig: {\n baseUrl:\n \"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"KAT-Coder-Pro\",\n name: \"KAT-Coder Pro\",\n contextWindow: 128000,\n cost: { input: 0.002, output: 0.006 },\n },\n ],\n },\n category: \"cn_official\",\n icon: \"catcoder\",\n templateValues: {\n baseUrl: {\n label: \"Base URL\",\n placeholder:\n \"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai\",\n defaultValue:\n \"https://vanchin.streamlake.ai/api/gateway/v1/endpoints/${ENDPOINT_ID}/openai\",\n editorValue: \"\",\n },\n ENDPOINT_ID: {\n label: \"Endpoint ID\",\n placeholder: \"\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"katcoder/KAT-Coder-Pro\" },\n modelCatalog: { \"katcoder/KAT-Coder-Pro\": { alias: \"KAT-Coder\" } },\n },\n },\n {\n name: \"Longcat\",\n websiteUrl: \"https://longcat.chat/platform\",\n apiKeyUrl: \"https://longcat.chat/platform/api_keys\",\n settingsConfig: {\n baseUrl: \"https://api.longcat.chat/openai/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n authHeader: true,\n models: [\n {\n id: \"LongCat-2.0\",\n name: \"LongCat 2.0\",\n reasoning: false,\n input: [\"text\"],\n contextWindow: 1048576,\n maxTokens: 131072,\n compat: { maxTokensField: \"max_tokens\" },\n cost: { input: 0.001, output: 0.004 },\n },\n ],\n },\n category: \"cn_official\",\n icon: \"longcat\",\n iconColor: \"#29E154\",\n templateValues: {\n baseUrl: {\n label: \"Base URL\",\n placeholder: \"https://api.longcat.chat/openai/v1\",\n defaultValue: \"https://api.longcat.chat/openai/v1\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"longcat/LongCat-2.0\" },\n modelCatalog: { \"longcat/LongCat-2.0\": { alias: \"LongCat\" } },\n },\n },\n {\n name: \"BaiLing\",\n websiteUrl: \"https://alipaytbox.yuque.com/sxs0ba/ling/get_started\",\n settingsConfig: {\n baseUrl: \"https://api.tbox.cn/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"Ling-2.5-1T\",\n name: \"Ling 2.5 1T\",\n contextWindow: 128000,\n cost: { input: 0.001, output: 0.004 },\n },\n ],\n },\n category: \"cn_official\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"bailing/Ling-2.5-1T\" },\n modelCatalog: { \"bailing/Ling-2.5-1T\": { alias: \"BaiLing\" } },\n },\n },\n {\n name: \"Xiaomi MiMo\",\n websiteUrl: \"https://platform.xiaomimimo.com\",\n apiKeyUrl: \"https://platform.xiaomimimo.com/#/console/api-keys\",\n settingsConfig: {\n baseUrl: \"https://api.xiaomimimo.com/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"mimo-v2.5-pro\",\n name: \"MiMo V2.5 Pro\",\n reasoning: true,\n input: [\"text\"],\n contextWindow: 1048576,\n maxTokens: 131072,\n cost: { input: 1, output: 3, cacheRead: 0.2, cacheWrite: 0 },\n },\n ],\n },\n category: \"cn_official\",\n icon: \"xiaomimimo\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"xiaomimimo/mimo-v2.5-pro\" },\n modelCatalog: { \"xiaomimimo/mimo-v2.5-pro\": { alias: \"MiMo\" } },\n },\n },\n {\n name: \"Xiaomi MiMo Token Plan (China)\",\n websiteUrl: \"https://platform.xiaomimimo.com/#/token-plan\",\n apiKeyUrl: \"https://platform.xiaomimimo.com/#/console/plan-manage\",\n settingsConfig: {\n baseUrl: \"https://token-plan-cn.xiaomimimo.com/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"mimo-v2.5-pro\",\n name: \"MiMo V2.5 Pro\",\n reasoning: true,\n input: [\"text\"],\n contextWindow: 1048576,\n maxTokens: 131072,\n },\n {\n id: \"mimo-v2.5\",\n name: \"MiMo V2.5\",\n reasoning: true,\n input: [\"text\", \"image\"],\n contextWindow: 1048576,\n maxTokens: 131072,\n },\n ],\n },\n category: \"cn_official\",\n icon: \"xiaomimimo\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"Token Plan API Key\",\n placeholder: \"tp-...\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"xiaomi-mimo-token-plan/mimo-v2.5-pro\" },\n modelCatalog: {\n \"xiaomi-mimo-token-plan/mimo-v2.5-pro\": {\n alias: \"MiMo Token Plan (China)\",\n },\n \"xiaomi-mimo-token-plan/mimo-v2.5\": {\n alias: \"MiMo Token Plan (China) Multimodal\",\n },\n },\n },\n },\n\n // ========== Aggregators ==========\n {\n name: \"AiHubMix\",\n websiteUrl: \"https://aihubmix.com\",\n apiKeyUrl: \"https://aihubmix.com\",\n settingsConfig: {\n baseUrl: \"https://aihubmix.com\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n {\n id: \"claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n cost: { input: 3, output: 15 },\n },\n ],\n },\n category: \"aggregator\",\n icon: \"aihubmix\",\n iconColor: \"#006FFB\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"aihubmix/claude-opus-4-8\",\n fallbacks: [\"aihubmix/claude-sonnet-5\"],\n },\n modelCatalog: {\n \"aihubmix/claude-opus-4-8\": { alias: \"Opus\" },\n \"aihubmix/claude-sonnet-5\": { alias: \"Sonnet\" },\n },\n },\n },\n {\n name: \"CherryIN\",\n websiteUrl: \"https://open.cherryin.ai\",\n apiKeyUrl: \"https://open.cherryin.ai/console/token\",\n settingsConfig: {\n baseUrl: \"https://open.cherryin.net\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"anthropic/claude-opus-4.8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n },\n {\n id: \"anthropic/claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n },\n ],\n },\n category: \"aggregator\",\n icon: \"cherryin\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"cherryin/anthropic/claude-opus-4.8\",\n fallbacks: [\"cherryin/anthropic/claude-sonnet-5\"],\n },\n modelCatalog: {\n \"cherryin/anthropic/claude-opus-4.8\": { alias: \"Opus\" },\n \"cherryin/anthropic/claude-sonnet-5\": { alias: \"Sonnet\" },\n },\n },\n },\n {\n name: \"DMXAPI\",\n websiteUrl: \"https://www.dmxapi.cn\",\n apiKeyUrl: \"https://www.dmxapi.cn\",\n settingsConfig: {\n baseUrl: \"https://www.dmxapi.cn\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n {\n id: \"claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n cost: { input: 3, output: 15 },\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"dmxapi\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"dmxapi/claude-opus-4-8\",\n fallbacks: [\"dmxapi/claude-sonnet-5\"],\n },\n modelCatalog: {\n \"dmxapi/claude-opus-4-8\": { alias: \"Opus\" },\n \"dmxapi/claude-sonnet-5\": { alias: \"Sonnet\" },\n },\n },\n },\n {\n name: \"ClaudeCN\",\n websiteUrl: \"https://claudecn.top\",\n apiKeyUrl: \"https://claudecn.top/register?aff=ccswitch\",\n settingsConfig: {\n baseUrl: \"https://claudecn.top\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n },\n {\n id: \"claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n },\n {\n id: \"claude-haiku-4-5\",\n name: \"Claude Haiku 4.5\",\n contextWindow: 200000,\n },\n ],\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"claudecn\",\n icon: \"claudecn\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"claudecn/claude-sonnet-5\",\n },\n modelCatalog: {\n \"claudecn/claude-opus-4-8\": { alias: \"Opus\" },\n \"claudecn/claude-sonnet-5\": { alias: \"Sonnet\" },\n \"claudecn/claude-haiku-4-5\": { alias: \"Haiku\" },\n },\n },\n },\n {\n name: \"RunAPI\",\n websiteUrl: \"https://runapi.co\",\n apiKeyUrl: \"https://runapi.co\",\n settingsConfig: {\n baseUrl: \"https://runapi.co\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n },\n {\n id: \"claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n },\n {\n id: \"claude-haiku-4-5\",\n name: \"Claude Haiku 4.5\",\n contextWindow: 200000,\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"runapi\",\n icon: \"runapi\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"runapi/claude-sonnet-5\",\n },\n modelCatalog: {\n \"runapi/claude-opus-4-8\": { alias: \"Opus\" },\n \"runapi/claude-sonnet-5\": { alias: \"Sonnet\" },\n \"runapi/claude-haiku-4-5\": { alias: \"Haiku\" },\n },\n },\n },\n {\n name: \"OpenRouter\",\n websiteUrl: \"https://openrouter.ai\",\n apiKeyUrl: \"https://openrouter.ai/keys\",\n settingsConfig: {\n baseUrl: \"https://openrouter.ai/api/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"anthropic/claude-opus-4.8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n {\n id: \"anthropic/claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n cost: { input: 3, output: 15 },\n },\n ],\n },\n category: \"aggregator\",\n icon: \"openrouter\",\n iconColor: \"#6566F1\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-or-...\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"openrouter/anthropic/claude-opus-4.8\",\n fallbacks: [\"openrouter/anthropic/claude-sonnet-5\"],\n },\n modelCatalog: {\n \"openrouter/anthropic/claude-opus-4.8\": { alias: \"Opus\" },\n \"openrouter/anthropic/claude-sonnet-5\": { alias: \"Sonnet\" },\n },\n },\n },\n {\n name: \"TheRouter\",\n websiteUrl: \"https://therouter.ai\",\n apiKeyUrl: \"https://dashboard.therouter.ai\",\n settingsConfig: {\n baseUrl: \"https://api.therouter.ai/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"anthropic/claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },\n },\n {\n id: \"openai/gpt-5.3-codex\",\n name: \"GPT-5.3 Codex\",\n contextWindow: 400000,\n cost: { input: 5, output: 40, cacheRead: 0.5 },\n },\n {\n id: \"openai/gpt-5.2\",\n name: \"GPT-5.2\",\n contextWindow: 400000,\n cost: { input: 1.75, output: 14, cacheRead: 0.175 },\n },\n {\n id: \"google/gemini-3.5-flash\",\n name: \"Gemini 3.5 Flash\",\n contextWindow: 1000000,\n cost: { input: 1.5, output: 9, cacheRead: 0.15 },\n },\n {\n id: \"qwen/qwen3-coder-480b\",\n name: \"Qwen3 Coder 480B\",\n contextWindow: 262144,\n cost: { input: 0.6, output: 2.35 },\n },\n ],\n },\n category: \"aggregator\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-...\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"therouter/anthropic/claude-sonnet-5\",\n fallbacks: [\n \"therouter/openai/gpt-5.2\",\n \"therouter/google/gemini-3.5-flash\",\n ],\n },\n modelCatalog: {\n \"therouter/anthropic/claude-sonnet-5\": { alias: \"Sonnet\" },\n \"therouter/openai/gpt-5.2\": { alias: \"GPT-5.2\" },\n \"therouter/google/gemini-3.5-flash\": { alias: \"Gemini Flash\" },\n \"therouter/openai/gpt-5.3-codex\": { alias: \"Codex\" },\n \"therouter/qwen/qwen3-coder-480b\": { alias: \"Qwen Coder\" },\n },\n },\n },\n {\n name: \"ModelScope\",\n websiteUrl: \"https://modelscope.cn\",\n apiKeyUrl: \"https://modelscope.cn/my/myaccesstoken\",\n settingsConfig: {\n baseUrl: \"https://api-inference.modelscope.cn/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"ZhipuAI/GLM-5.1\",\n name: \"GLM-5.1\",\n contextWindow: 128000,\n cost: { input: 0.001, output: 0.001 },\n },\n ],\n },\n category: \"aggregator\",\n icon: \"modelscope\",\n iconColor: \"#624AFF\",\n templateValues: {\n baseUrl: {\n label: \"Base URL\",\n placeholder: \"https://api-inference.modelscope.cn/v1\",\n defaultValue: \"https://api-inference.modelscope.cn/v1\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"modelscope/ZhipuAI/GLM-5.1\" },\n modelCatalog: { \"modelscope/ZhipuAI/GLM-5.1\": { alias: \"GLM\" } },\n },\n },\n {\n name: \"SiliconFlow\",\n websiteUrl: \"https://siliconflow.cn\",\n apiKeyUrl: \"https://cloud.siliconflow.cn/i/YflgU2Ve\",\n settingsConfig: {\n baseUrl: \"https://api.siliconflow.cn/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"Pro/MiniMaxAI/MiniMax-M2.7\",\n name: \"MiniMax M2.7\",\n contextWindow: 200000,\n cost: { input: 0.001, output: 0.004 },\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"siliconflow\",\n icon: \"siliconflow\",\n iconColor: \"#6E29F6\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-...\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"siliconflow/Pro/MiniMaxAI/MiniMax-M2.7\" },\n modelCatalog: {\n \"siliconflow/Pro/MiniMaxAI/MiniMax-M2.7\": { alias: \"MiniMax\" },\n },\n },\n },\n {\n name: \"SiliconFlow en\",\n websiteUrl: \"https://siliconflow.com\",\n apiKeyUrl: \"https://cloud.siliconflow.cn/i/YflgU2Ve\",\n settingsConfig: {\n baseUrl: \"https://api.siliconflow.com/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"MiniMaxAI/MiniMax-M2.7\",\n name: \"MiniMax M2.7\",\n contextWindow: 200000,\n cost: { input: 0.001, output: 0.004 },\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"siliconflow\",\n icon: \"siliconflow\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-...\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"siliconflow-en/MiniMaxAI/MiniMax-M2.7\" },\n modelCatalog: {\n \"siliconflow-en/MiniMaxAI/MiniMax-M2.7\": { alias: \"MiniMax\" },\n },\n },\n },\n {\n name: \"Novita AI\",\n websiteUrl: \"https://novita.ai\",\n apiKeyUrl: \"https://novita.ai\",\n settingsConfig: {\n baseUrl: \"https://api.novita.ai/openai\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"zai-org/glm-5.1\",\n name: \"GLM-5.1\",\n contextWindow: 202800,\n cost: { input: 1, output: 3.2, cacheRead: 0.2 },\n },\n ],\n },\n category: \"aggregator\",\n icon: \"novita\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-...\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"novita/zai-org/glm-5.1\" },\n modelCatalog: {\n \"novita/zai-org/glm-5.1\": { alias: \"GLM-5.1\" },\n },\n },\n },\n {\n name: \"Nvidia\",\n websiteUrl: \"https://build.nvidia.com\",\n apiKeyUrl: \"https://build.nvidia.com/settings/api-keys\",\n settingsConfig: {\n baseUrl: \"https://integrate.api.nvidia.com/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"moonshotai/kimi-k2.5\",\n name: \"Kimi K2.5\",\n contextWindow: 131072,\n cost: { input: 0.002, output: 0.006 },\n },\n ],\n },\n category: \"aggregator\",\n icon: \"nvidia\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"nvapi-...\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: { primary: \"nvidia/moonshotai/kimi-k2.5\" },\n modelCatalog: { \"nvidia/moonshotai/kimi-k2.5\": { alias: \"Kimi\" } },\n },\n },\n {\n name: \"PIPELLM\",\n websiteUrl: \"https://code.pipellm.ai\",\n apiKeyUrl: \"https://code.pipellm.ai/login?ref=uvw650za\",\n settingsConfig: {\n baseUrl: \"https://cc-api.pipellm.ai\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"claude-opus-4-8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n {\n id: \"claude-sonnet-5\",\n name: \"claude-sonnet-5\",\n contextWindow: 1000000,\n cost: { input: 3, output: 15 },\n },\n {\n id: \"claude-haiku-4-5-20251001\",\n name: \"claude-haiku-4-5-20251001\",\n contextWindow: 200000,\n cost: { input: 0.8, output: 4 },\n },\n ],\n },\n category: \"aggregator\",\n icon: \"pipellm\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"pipe-...\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"pipellm/claude-opus-4-8\",\n fallbacks: [\"pipellm/claude-sonnet-5\"],\n },\n modelCatalog: {\n \"pipellm/claude-opus-4-8\": { alias: \"Opus\" },\n \"pipellm/claude-sonnet-5\": { alias: \"Sonnet\" },\n \"pipellm/claude-haiku-4-5-20251001\": { alias: \"Haiku\" },\n },\n },\n },\n\n // ========== Third Party Partners ==========\n {\n name: \"PackyCode\",\n websiteUrl: \"https://www.packyapi.com\",\n apiKeyUrl: \"https://www.packyapi.com/register?aff=cc-switch\",\n settingsConfig: {\n baseUrl: \"https://www.packyapi.com\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n {\n id: \"claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n cost: { input: 3, output: 15 },\n },\n ],\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"packycode\",\n icon: \"packycode\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"packycode/claude-opus-4-8\",\n fallbacks: [\"packycode/claude-sonnet-5\"],\n },\n modelCatalog: {\n \"packycode/claude-opus-4-8\": { alias: \"Opus\" },\n \"packycode/claude-sonnet-5\": { alias: \"Sonnet\" },\n },\n },\n },\n {\n name: \"APIKEY.FUN\",\n websiteUrl: \"https://apikey.fun\",\n apiKeyUrl: \"https://apikey.fun/register?aff=CCSwitch\",\n settingsConfig: {\n baseUrl: \"https://api.apikey.fun\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n },\n {\n id: \"claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n },\n {\n id: \"claude-haiku-4-5\",\n name: \"Claude Haiku 4.5\",\n contextWindow: 200000,\n },\n ],\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"apikeyfun\",\n icon: \"apikeyfun\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"apikeyfun/claude-opus-4-8\",\n fallbacks: [\"apikeyfun/claude-sonnet-5\"],\n },\n modelCatalog: {\n \"apikeyfun/claude-opus-4-8\": { alias: \"Opus\" },\n \"apikeyfun/claude-sonnet-5\": { alias: \"Sonnet\" },\n \"apikeyfun/claude-haiku-4-5\": { alias: \"Haiku\" },\n },\n },\n },\n {\n name: \"APINebula\",\n websiteUrl: \"https://apinebula.com\",\n apiKeyUrl: \"https://apinebula.com/02rw5X\",\n settingsConfig: {\n baseUrl: \"https://apinebula.com/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"gpt-5.5\",\n name: \"GPT-5.5\",\n },\n ],\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"apinebula\",\n icon: \"apinebula\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"apinebula/gpt-5.5\",\n },\n },\n },\n {\n name: \"AtlasCloud\",\n websiteUrl: \"https://www.atlascloud.ai/console/coding-plan\",\n apiKeyUrl: \"https://www.atlascloud.ai/console/coding-plan\",\n settingsConfig: {\n baseUrl: \"https://api.atlascloud.ai/v1\",\n apiKey: \"\",\n api: \"openai-completions\",\n models: [\n {\n id: \"zai-org/glm-5.1\",\n name: \"GLM 5.1\",\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true,\n partnerPromotionKey: \"atlascloud\",\n icon: \"atlascloud\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"atlascloud/zai-org/glm-5.1\",\n },\n },\n },\n {\n name: \"SudoCode\",\n websiteUrl: \"https://sudocode.chat\",\n apiKeyUrl:\n \"https://sudocode.chat/register?utm_source=ccswitch&utm_medium=partner\",\n settingsConfig: {\n baseUrl: \"https://api.sudocode.chat/v1\",\n apiKey: \"\",\n api: \"openai-responses\",\n models: [\n {\n id: \"gpt-5.6-sol\",\n name: \"GPT-5.6 Sol\",\n },\n ],\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"sudocode\",\n icon: \"sudocode\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"sudocode/gpt-5.6-sol\",\n },\n },\n },\n {\n name: \"Cubence\",\n websiteUrl: \"https://cubence.com\",\n apiKeyUrl: \"https://cubence.com/signup?code=CCSWITCH&source=ccs\",\n settingsConfig: {\n baseUrl: \"https://api.cubence.com\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n {\n id: \"claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n cost: { input: 3, output: 15 },\n },\n ],\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"cubence\",\n icon: \"cubence\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"cubence/claude-opus-4-8\",\n fallbacks: [\"cubence/claude-sonnet-5\"],\n },\n modelCatalog: {\n \"cubence/claude-opus-4-8\": { alias: \"Opus\" },\n \"cubence/claude-sonnet-5\": { alias: \"Sonnet\" },\n },\n },\n },\n {\n name: \"AIGoCode\",\n websiteUrl: \"https://aigocode.com\",\n apiKeyUrl: \"https://aigocode.com/invite/CC-SWITCH\",\n settingsConfig: {\n baseUrl: \"https://api.aigocode.com\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n {\n id: \"claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n cost: { input: 3, output: 15 },\n },\n ],\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"aigocode\",\n icon: \"aigocode\",\n iconColor: \"#5B7FFF\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"aigocode/claude-opus-4-8\",\n fallbacks: [\"aigocode/claude-sonnet-5\"],\n },\n modelCatalog: {\n \"aigocode/claude-opus-4-8\": { alias: \"Opus\" },\n \"aigocode/claude-sonnet-5\": { alias: \"Sonnet\" },\n },\n },\n },\n {\n name: \"RightCode\",\n websiteUrl: \"https://www.right.codes\",\n apiKeyUrl: \"https://www.right.codes/register?aff=CCSWITCH\",\n settingsConfig: {\n baseUrl: \"https://www.right.codes/claude\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n {\n id: \"claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n cost: { input: 3, output: 15 },\n },\n ],\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"rightcode\",\n icon: \"rc\",\n iconColor: \"#E96B2C\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"rightcode/claude-opus-4-8\",\n fallbacks: [\"rightcode/claude-sonnet-5\"],\n },\n modelCatalog: {\n \"rightcode/claude-opus-4-8\": { alias: \"Opus\" },\n \"rightcode/claude-sonnet-5\": { alias: \"Sonnet\" },\n },\n },\n },\n {\n name: \"AICodeMirror\",\n websiteUrl: \"https://www.aicodemirror.com\",\n apiKeyUrl: \"https://www.aicodemirror.com/register?invitecode=9915W3\",\n settingsConfig: {\n baseUrl: \"https://api.aicodemirror.com/api/claudecode\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n {\n id: \"claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n cost: { input: 3, output: 15 },\n },\n ],\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"aicodemirror\",\n icon: \"aicodemirror\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"aicodemirror/claude-opus-4-8\",\n fallbacks: [\"aicodemirror/claude-sonnet-5\"],\n },\n modelCatalog: {\n \"aicodemirror/claude-opus-4-8\": { alias: \"Opus\" },\n \"aicodemirror/claude-sonnet-5\": { alias: \"Sonnet\" },\n },\n },\n },\n {\n name: \"CrazyRouter\",\n websiteUrl: \"https://www.crazyrouter.com\",\n apiKeyUrl: \"https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch\",\n settingsConfig: {\n baseUrl: \"https://cn.crazyrouter.com/v1\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n {\n id: \"claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n cost: { input: 3, output: 15 },\n },\n ],\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"crazyrouter\",\n icon: \"crazyrouter\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"crazyrouter/claude-opus-4-8\",\n fallbacks: [\"crazyrouter/claude-sonnet-5\"],\n },\n modelCatalog: {\n \"crazyrouter/claude-opus-4-8\": { alias: \"Opus\" },\n \"crazyrouter/claude-sonnet-5\": { alias: \"Sonnet\" },\n },\n },\n },\n {\n name: \"SSSAiCode\",\n websiteUrl: \"https://sssaicodeapi.com\",\n apiKeyUrl: \"https://sssaicodeapi.com/register?ref=DCP0SM\",\n settingsConfig: {\n baseUrl: \"https://node-hk.sssaicodeapi.com/api\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n {\n id: \"claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n cost: { input: 3, output: 15 },\n },\n ],\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"sssaicode\",\n icon: \"sssaicode\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"sssaicode/claude-opus-4-8\",\n fallbacks: [\"sssaicode/claude-sonnet-5\"],\n },\n modelCatalog: {\n \"sssaicode/claude-opus-4-8\": { alias: \"Opus\" },\n \"sssaicode/claude-sonnet-5\": { alias: \"Sonnet\" },\n },\n },\n },\n {\n name: \"Compshare\",\n nameKey: \"providerForm.presets.ucloud\",\n websiteUrl: \"https://www.compshare.cn\",\n apiKeyUrl:\n \"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch\",\n settingsConfig: {\n baseUrl: \"https://api.modelverse.cn/v1\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true, // 合作伙伴\n partnerPromotionKey: \"ucloud\", // 促销信息 i18n key\n icon: \"ucloud\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"compshare/claude-opus-4-8\",\n },\n modelCatalog: {\n \"compshare/claude-opus-4-8\": { alias: \"Opus\" },\n },\n },\n },\n {\n name: \"Compshare Coding Plan\",\n nameKey: \"providerForm.presets.ucloudCoding\",\n websiteUrl: \"https://www.compshare.cn\",\n apiKeyUrl:\n \"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch\",\n settingsConfig: {\n baseUrl: \"https://cp.compshare.cn/v1\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n ],\n },\n category: \"aggregator\",\n isPartner: true, // 合作伙伴\n partnerPromotionKey: \"ucloud\", // 促销信息 i18n key(复用)\n icon: \"ucloud\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"compshare-coding/claude-opus-4-8\",\n },\n modelCatalog: {\n \"compshare-coding/claude-opus-4-8\": { alias: \"Opus\" },\n },\n },\n },\n {\n name: \"Micu\",\n websiteUrl: \"https://www.micuapi.ai\",\n apiKeyUrl: \"https://www.micuapi.ai/register?aff=aOYQ\",\n settingsConfig: {\n baseUrl: \"https://www.micuapi.ai\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n ],\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"micu\",\n icon: \"micu\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"micu/claude-opus-4-8\",\n },\n modelCatalog: {\n \"micu/claude-opus-4-8\": { alias: \"Opus\" },\n },\n },\n },\n {\n name: \"ETok.ai\",\n websiteUrl: \"https://etok.ai\",\n apiKeyUrl: \"https://etok.ai\",\n settingsConfig: {\n baseUrl: \"https://api.etok.ai\",\n apiKey: \"\",\n api: \"anthropic-messages\",\n models: [\n {\n id: \"claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25 },\n },\n ],\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"etok\",\n icon: \"etok\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"etok/claude-opus-4-8\",\n },\n modelCatalog: {\n \"etok/claude-opus-4-8\": { alias: \"Opus\" },\n },\n },\n },\n {\n name: \"E-FlowCode\",\n websiteUrl: \"https://e-flowcode.cc\",\n apiKeyUrl: \"https://e-flowcode.cc\",\n settingsConfig: {\n api: \"openai-responses\",\n apiKey: \"\",\n baseUrl: \"https://e-flowcode.cc/v1\",\n headers: {\n \"User-Agent\":\n \"codex_cli_rs/0.77.0 (Windows 10.0.26100; x86_64) WindowsTerminal\",\n },\n models: [\n {\n contextWindow: 200000,\n cost: {\n cacheRead: 0,\n cacheWrite: 0,\n input: 0,\n output: 0,\n },\n id: \"gpt-5.3-codex\",\n maxTokens: 32000,\n name: \"gpt-5.3-codex\",\n },\n {\n id: \"gpt-5.5\",\n name: \"gpt-5.5\",\n },\n {\n id: \"gpt-5.2-codex\",\n name: \"gpt-5.2-codex\",\n },\n {\n id: \"gpt-5.2\",\n name: \"gpt-5.2\",\n },\n ],\n },\n category: \"third_party\",\n icon: \"eflowcode\",\n iconColor: \"#000000\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"sk-...\",\n editorValue: \"\",\n },\n },\n suggestedDefaults: {\n model: {\n primary: \"eflowcode/gpt-5.3-codex\",\n fallbacks: [\"eflowcode/gpt-5.5\", \"eflowcode/gpt-5.2-codex\"],\n },\n modelCatalog: {\n \"eflowcode/gpt-5.3-codex\": { alias: \"gpt-5.3-codex\" },\n \"eflowcode/gpt-5.5\": { alias: \"gpt-5.5\" },\n \"eflowcode/gpt-5.2-codex\": { alias: \"gpt-5.2-codex\" },\n \"eflowcode/gpt-5.2\": { alias: \"gpt-5.2\" },\n },\n },\n },\n // ========== Cloud Providers ==========\n {\n name: \"AWS Bedrock\",\n websiteUrl: \"https://aws.amazon.com/bedrock/\",\n settingsConfig: {\n // 请将 us-west-2 替换为你的 AWS Region\n baseUrl: \"https://bedrock-runtime.us-west-2.amazonaws.com\",\n apiKey: \"\",\n api: \"bedrock-converse-stream\",\n models: [\n {\n id: \"anthropic.claude-opus-4-8\",\n name: \"Claude Opus 4.8\",\n contextWindow: 1000000,\n cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },\n },\n {\n id: \"anthropic.claude-sonnet-5\",\n name: \"Claude Sonnet 5\",\n contextWindow: 1000000,\n cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },\n },\n {\n id: \"anthropic.claude-haiku-4-5-20251022-v1:0\",\n name: \"Claude Haiku 4.5\",\n contextWindow: 200000,\n cost: { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },\n },\n ],\n },\n category: \"cloud_provider\",\n icon: \"aws\",\n iconColor: \"#FF9900\",\n },\n];\n"}}, "src/i18n/locales/ja.json": {"1e398a3709f03db3fe5d989827090386b9dcd3be": {"t": 1785674687.2153597, "n": 124925, "h": "f5d04bc8e3413249", "c": "{\n \"app\": {\n \"title\": \"CC Switch\",\n \"description\": \"Claude Code・Codex・Gemini CLI のためのオールインワンアシスタント\"\n },\n \"common\": {\n \"add\": \"追加\",\n \"edit\": \"編集\",\n \"delete\": \"削除\",\n \"save\": \"保存\",\n \"saving\": \"保存中...\",\n \"cancel\": \"キャンセル\",\n \"confirm\": \"確認\",\n \"close\": \"閉じる\",\n \"done\": \"完了\",\n \"settings\": \"設定\",\n \"about\": \"バージョン情報\",\n \"version\": \"バージョン\",\n \"loading\": \"読み込み中...\",\n \"notInstalled\": \"未インストール\",\n \"success\": \"成功\",\n \"error\": \"エラー\",\n \"unknown\": \"不明\",\n \"enterValidValue\": \"有効な値を入力してください\",\n \"clear\": \"クリア\",\n \"toggleTheme\": \"テーマを切り替え\",\n \"format\": \"フォーマット\",\n \"formatSuccess\": \"整形しました\",\n \"formatError\": \"整形に失敗しました: {{error}}\",\n \"copy\": \"コピー\",\n \"view\": \"表示\",\n \"back\": \"戻る\",\n \"refresh\": \"更新\",\n \"refreshing\": \"更新中...\",\n \"import\": \"インポート\",\n \"all\": \"すべて\",\n \"search\": \"検索\",\n \"reset\": \"リセット\",\n \"actions\": \"操作\",\n \"deleting\": \"削除中...\",\n \"auto\": \"自動\",\n \"enabled\": \"有効\",\n \"notSet\": \"未設定\"\n },\n \"firstRunNotice\": {\n \"title\": \"CC Switch へようこそ\",\n \"bodyDefault\": \"CC Switch は Claude Code / Codex / Gemini CLI の複数プロバイダーをワンクリックで切り替えられるツールです。すでにこれらのツールを設定済みの場合、CC Switch は現在の設定を「default」という名前のプロバイダーとして自動的に保存するので、既存の設定が失われることはありません。\",\n \"bodyOfficial\": \"リストには「Official(公式)」プリセットも用意されており、公式バージョンに戻したくなったらクリックするだけで切り替えられます。切り替える前に現在の設定は自動で default にバックアップされるので、自由に行き来できます。これが CC Switch の仕組みです 😊\",\n \"confirm\": \"了解しました\"\n },\n \"apiKeyInput\": {\n \"placeholder\": \"API Key を入力\",\n \"show\": \"API Key を表示\",\n \"hide\": \"API Key を隠す\"\n },\n \"jsonEditor\": {\n \"mustBeObject\": \"設定はオブジェクト形式の JSON で入力してください(配列や他の型は不可)\",\n \"invalidJson\": \"JSON 形式が正しくありません\"\n },\n \"commonConfig\": {\n \"guideTitle\": \"共通設定スニペットとは?\",\n \"guidePurpose\": \"異なるプロバイダー間でプラグインや環境変数などの非機密設定を共有するための機能です。プロバイダーを切り替えてもこれらの設定は失われません。\",\n \"guideUsage\": \"使い方:① 「編集内容から抽出」をクリックして共通部分を保存 ② 新規プロバイダー作成時に「共通設定を適用」をチェック\",\n \"guideReExtract\": \"新しいプラグインや Hook をインストールした場合は、共通設定を再抽出して他のプロバイダーに同期してください。\",\n \"guideReassurance\": \"ご安心ください:元の設定はデフォルトプロバイダーに安全に保存されており、失われることはありません。\",\n \"emptyTitle\": \"共通設定スニペットがまだありません\",\n \"emptyHint\": \"下の「編集内容から抽出」ボタンをクリックして、現在の設定から再利用可能な部分を抽出してください\"\n },\n \"claudeConfig\": {\n \"configLabel\": \"Claude Code settings.json (JSON) *\",\n \"writeCommonConfig\": \"共通設定を適用\",\n \"editCommonConfig\": \"共通設定を編集\",\n \"editCommonConfigTitle\": \"共通設定スニペットを編集\",\n \"commonConfigHint\": \"「共通設定を適用」がオンのとき settings.json にマージされます\",\n \"fullSettingsHint\": \"Claude Code の settings.json 全文\",\n \"extractFromCurrent\": \"編集内容から抽出\",\n \"extractNoCommonConfig\": \"編集内容から抽出できる共通設定がありません\",\n \"extractFailed\": \"抽出に失敗しました: {{error}}\",\n \"saveFailed\": \"保存に失敗しました: {{error}}\",\n \"hideAttribution\": \"AI署名を非表示\",\n \"enableTeammates\": \"Teammates モード\",\n \"enableToolSearch\": \"Tool Search を有効化\",\n \"effortMax\": \"最大強度思考\",\n \"disableAutoUpgrade\": \"自動アップグレードを無効化\"\n },\n \"header\": {\n \"viewOnGithub\": \"GitHub で見る\",\n \"toggleDarkMode\": \"ダークモードに切り替え\",\n \"toggleLightMode\": \"ライトモードに切り替え\",\n \"addProvider\": \"プロバイダーを追加\",\n \"switchToChinese\": \"中国語に切り替え\",\n \"switchToEnglish\": \"英語に切り替え\",\n \"enterEditMode\": \"編集モードに入る\",\n \"exitEditMode\": \"編集モードを終了\",\n \"windowMinimize\": \"ウィンドウを最小化\",\n \"windowMaximize\": \"ウィンドウを最大化\",\n \"windowRestore\": \"ウィンドウを元に戻す\",\n \"windowClose\": \"ウィンドウを閉じる\"\n },\n \"provider\": {\n \"connectivityCheck\": \"接続チェック\",\n \"tabProvider\": \"プロバイダー\",\n \"tabUniversal\": \"統一プロバイダー\",\n \"noProviders\": \"まだプロバイダーがありません\",\n \"noProvidersDescription\": \"既存の設定がある場合は「現在の設定をインポート」をクリックしてください。すべてのデータが default プロバイダーに安全に保存されます\",\n \"noProvidersDescriptionSnippet\": \"API キーとリクエスト URL 以外のデータ(プラグインなど)は共通設定スニペットに保存され、プロバイダー間で共有できます\",\n \"importCurrent\": \"現在の設定をインポート\",\n \"importFromClaude\": \"Claude Code の既存プロバイダーをインポート\",\n \"importCurrentDescription\": \"現在使用中の設定をデフォルトプロバイダーとしてインポート\",\n \"currentlyUsing\": \"現在使用中\",\n \"enable\": \"有効化\",\n \"inUse\": \"使用中\",\n \"blockedByProxyHint\": \"プロキシ引き継ぎモードでは公式プロバイダーに切り替えできません\",\n \"editProvider\": \"プロバイダーを編集\",\n \"editProviderHint\": \"保存すると現在のプロバイダーにすぐ反映されます。\",\n \"deleteProvider\": \"プロバイダーを削除\",\n \"addNewProvider\": \"新しいプロバイダーを追加\",\n \"addFooterHint\": \"💡 プリセットを選んだら、下のフィールド(API Key など)を入力してください\",\n \"addClaudeProvider\": \"Claude Code プロバイダーを追加\",\n \"addCodexProvider\": \"Codex プロバイダーを追加\",\n \"addGeminiProvider\": \"Gemini プロバイダーを追加\",\n \"addOpenCodeProvider\": \"OpenCode プロバイダーを追加\",\n \"addToConfig\": \"追加\",\n \"removeFromConfig\": \"削除\",\n \"setAsDefault\": \"デフォルトに設定\",\n \"isDefault\": \"現在のデフォルト\",\n \"inConfig\": \"追加済み\",\n \"addProviderHint\": \"一覧にすばやく切り替えられるよう、ここに情報を入力してください。\",\n \"editClaudeProvider\": \"Claude Code プロバイダーを編集\",\n \"editCodexProvider\": \"Codex プロバイダーを編集\",\n \"configError\": \"設定エラー\",\n \"notConfigured\": \"公式サイト用に未設定\",\n \"applyToClaudePlugin\": \"Claude プラグインに適用\",\n \"removeFromClaudePlugin\": \"Claude プラグインから解除\",\n \"dragToReorder\": \"ドラッグで並べ替え\",\n \"dragHandle\": \"ドラッグで並べ替え\",\n \"searchPlaceholder\": \"名前・メモ・URLで検索...\",\n \"searchAriaLabel\": \"プロバイダーを検索\",\n \"searchScopeHint\": \"名前・メモ・URL を対象に検索します。\",\n \"searchCloseHint\": \"Esc で閉じる\",\n \"searchCloseAriaLabel\": \"検索を閉じる\",\n \"noSearchResults\": \"一致するプロバイダーがありません。\",\n \"duplicate\": \"複製\",\n \"sortUpdateFailed\": \"並び順の更新に失敗しました\",\n \"configureUsage\": \"利用状況を設定\",\n \"officialPartner\": \"公式パートナー\",\n \"managedByHermes\": \"Hermes 管理\",\n \"managedByHermesHint\": \"Hermes の providers: dict で定義されています。Hermes Web UI で編集または削除してください。\",\n \"openTerminal\": \"ターミナルを開く\",\n \"terminalOpened\": \"ターミナルを開きました\",\n \"terminalOpenFailed\": \"ターミナルを開けませんでした\",\n \"name\": \"プロバイダー名\",\n \"namePlaceholder\": \"例: Claude Official\",\n \"websiteUrl\": \"Web サイト URL\",\n \"notes\": \"メモ\",\n \"notesPlaceholder\": \"例: 会社用アカウント\",\n \"configJson\": \"Config JSON\",\n \"writeCommonConfig\": \"共通設定を適用\",\n \"editCommonConfigButton\": \"共通設定を編集\",\n \"configJsonHint\": \"Claude Code の設定をすべて入力してください\",\n \"editCommonConfigTitle\": \"共通設定スニペットを編集\",\n \"editCommonConfigHint\": \"共通設定スニペットは、この機能をオンにしたすべてのプロバイダーへマージされます\",\n \"addProvider\": \"プロバイダーを追加\",\n \"sortUpdated\": \"並び順を更新しました\",\n \"usageSaved\": \"利用状況の設定を保存しました\",\n \"usageSaveFailed\": \"利用状況設定の保存に失敗しました\",\n \"geminiConfig\": \"Gemini 設定\",\n \"geminiConfigHint\": \".env 形式で Gemini を設定してください\",\n \"form\": {\n \"gemini\": {\n \"model\": \"モデル\",\n \"oauthTitle\": \"OAuth 認証モード\",\n \"oauthHint\": \"Google 公式は OAuth 個人認証を使用するため API Key は不要です。初回利用時にブラウザが開きます。\",\n \"apiKeyPlaceholder\": \"Gemini API Key を入力\"\n }\n }\n },\n \"claudeCode\": {\n \"needsRouting\": \"ルーティングが必要\",\n \"noRoutingSupport\": \"ルーティング非対応\"\n },\n \"codex\": {\n \"needsRouting\": \"ルーティングが必要\",\n \"noRoutingSupport\": \"ルーティング非対応\",\n \"officialRouting\": \"公式アカウントルーティング\",\n \"nativeLogin\": \"Codex ログイン\"\n },\n \"claudeDesktop\": {\n \"officialNotice\": \"Claude Desktop の公式プロバイダーは内蔵の 1P サインインを使用します。API キーやエンドポイントの設定は不要です。\",\n \"mode\": \"モデル処理方式\",\n \"modeDirect\": \"直結\",\n \"modeProxy\": \"ルーティング必要\",\n \"modelMappingToggle\": \"モデルマッピングが必要\",\n \"modelMappingOffHint\": \"プロバイダーが Claude Desktop の三つの役割 ID(claude-sonnet-* / claude-opus-* / claude-haiku-*)を受け付ける場合のみ直結できます。それ以外のモデル名(claude-3-5-sonnet-… などの旧式 ID を含む)はこのスイッチを有効にしてマッピングを使ってください。\",\n \"modelMappingOnHint\": \"Claude Desktop は claude-sonnet-* / claude-opus-* / claude-haiku-* の三つの役割 ID のみ受け付けます。有効にすると CC Switch がこの三役割をプロバイダーの実モデルにマッピングし、使用中はローカルルーティングを起動したままにします。\",\n \"routeMapTitle\": \"モデルマッピング\",\n \"routeMapHint\": \"Sonnet・Opus・Haiku の各区分に実際のリクエストモデルを入力してください。DeepSeek や Kimi などのブランド名はメニュー表示名に入れられます。空欄の区分は Sonnet(または最初に入力した区分)のモデルを引き継ぐため、サブ agent が呼び出す Haiku が常に利用可能になります。\",\n \"routeModelLabel\": \"モデル役割\",\n \"routeRoleSonnet\": \"Sonnet\",\n \"routeRoleOpus\": \"Opus\",\n \"routeRoleFable\": \"Fable\",\n \"routeRoleHaiku\": \"Haiku\",\n \"labelOverrideLabel\": \"メニュー表示名\",\n \"upstreamModelLabel\": \"リクエストモデル\",\n \"supports1mLabel\": \"1M 対応を宣言\",\n \"supports1mShort\": \"1M\",\n \"directModelListTitle\": \"Claude Desktop モデルを手動指定(高度・任意)\",\n \"directModelListCollapsedHint\": \"ネイティブ Claude モデルのプロバイダーでは通常不要です。Claude Desktop が /v1/models を自動取得します。\",\n \"directModelListHint\": \"プロバイダーの /v1/models が使えない、または Claude Desktop が認識できる Sonnet / Opus / Haiku モデル ID を返さない場合だけ入力してください。1M をオンにすると Claude Desktop に 1M コンテキスト対応を宣言します。\",\n \"directModelInvalid\": \"直結モデルは Claude Desktop が認識できる Sonnet / Opus / Haiku モデル ID を使う必要があります\",\n \"addModel\": \"モデルを追加\",\n \"addRoute\": \"モデルを追加\",\n \"routeInvalid\": \"モデル役割を選択し、リクエストモデルを入力してください\",\n \"routesRequired\": \"少なくとも 1 つのモデルマッピングが必要です\",\n \"statusTitle\": \"Claude Desktop 設定の確認が必要です\",\n \"statusUnsupported\": \"このプラットフォームでは Claude Desktop 3P 設定の書き込みはまだサポートされていません。\",\n \"statusStaleRawModels\": \"Claude Desktop profile に Sonnet / Opus / Haiku の役割ファミリー以外のモデル ID が含まれています。新しい Claude Desktop では拒否される可能性があります。現在のプロバイダーへ再度切り替えると修復できます。\",\n \"statusMissingRouteMappings\": \"現在のプロバイダーはモデルマッピングを有効にしていますが、有効なルートがありません。プロバイダーを編集し、少なくとも 1 つのマッピングを追加してください。\",\n \"statusGatewayTokenMissing\": \"ローカルルーティング token がまだ生成されていません。このプロバイダーへ再度切り替えると新しい token が書き込まれます。\",\n \"statusBaseUrlMismatch\": \"Claude Desktop profile の URL が現在のプロバイダーと一致しません。現在: {{actual}}、期待値: {{expected}}。現在のプロバイダーへ再度切り替えると修復できます。\",\n \"route\": {\n \"tooltip\": {\n \"active\": \"Claude Desktop ローカルルーティングは起動中です - {{address}}:{{port}}\",\n \"inactive\": \"モデルマッピングや形式変換が必要なプロバイダー向けに Claude Desktop ローカルルーティングを起動します。設定アドレス: {{address}}:{{port}}\"\n }\n }\n },\n \"notifications\": {\n \"providerAdded\": \"プロバイダーを追加しました\",\n \"providerSaved\": \"プロバイダー設定を保存しました\",\n \"providerDeleted\": \"プロバイダーを削除しました\",\n \"switchSuccess\": \"切り替え成功!\",\n \"codexRestartRequired\": \"切り替えました。反映するにはクライアントを再起動してください\",\n \"claudeDesktopRestartRequired\": \"切り替えました。反映するには Claude Desktop を再起動してください\",\n \"claudeDesktopProxyRestartRequired\": \"切り替えました。CC Switch を起動したまま Claude Desktop を再起動してください\",\n \"addToConfigSuccess\": \"設定に追加しました\",\n \"removeFromConfigSuccess\": \"設定から削除しました\",\n \"switchFailedTitle\": \"切り替えに失敗しました\",\n \"switchFailed\": \"切り替えに失敗しました: {{error}}\",\n \"autoImported\": \"既存設定からデフォルトプロバイダーを自動作成しました\",\n \"addFailed\": \"プロバイダーの追加に失敗しました: {{error}}\",\n \"saveFailed\": \"保存に失敗しました: {{error}}\",\n \"saveFailedGeneric\": \"保存に失敗しました。もう一度お試しください\",\n \"appliedToClaudePlugin\": \"Claude プラグインに適用しました\",\n \"removedFromClaudePlugin\": \"Claude プラグインから削除しました\",\n \"syncClaudePluginFailed\": \"Claude プラグインとの同期に失敗しました\",\n \"skipClaudeOnboardingFailed\": \"Claude Code の初回確認スキップに失敗しました\",\n \"clearClaudeOnboardingSkipFailed\": \"Claude Code の初回確認の復元に失敗しました\",\n \"updateSuccess\": \"プロバイダーを更新しました\",\n \"updateFailed\": \"プロバイダーの更新に失敗しました: {{error}}\",\n \"deleteSuccess\": \"プロバイダーを削除しました\",\n \"deleteFailed\": \"プロバイダーの削除に失敗しました: {{error}}\",\n \"settingsSaved\": \"設定を保存しました\",\n \"settingsSaveFailed\": \"設定の保存に失敗しました: {{error}}\",\n \"proxyRequiredForSwitch\": \"このプロバイダーは{{reason}}、ルーティングサービスが必要です。先にルーティングを起動してください\",\n \"proxyReasonCopilot\": \"GitHub Copilot を Claude プロバイダーとして使用しており\",\n \"proxyReasonOpenAIChat\": \"OpenAI Chat API フォーマットを使用しており\",\n \"proxyReasonOpenAIResponses\": \"OpenAI Responses API フォーマットを使用しており\",\n \"proxyReasonFullUrl\": \"完全 URL 接続モードが有効になっており\",\n \"openAIFormatHint\": \"このプロバイダーは OpenAI 互換フォーマットを使用しており、ルーティングサービスの有効化が必要です\",\n \"copilotProxyHint\": \"GitHub Copilot を Claude プロバイダーとして使用する場合、ローカルルーティングが常に必要です。ルーティングは現在のモデルに応じて Chat Completions または Responses を自動的に選択します。\",\n \"openLinkFailed\": \"リンクを開けませんでした\",\n \"openclawModelsRegistered\": \"モデルが /model リストに登録されました\",\n \"openclawDefaultModelSet\": \"デフォルトモデルに設定しました\",\n \"openclawDefaultModelSetFailed\": \"デフォルトモデルの設定に失敗しました\",\n \"openclawNoModels\": \"モデルが設定されていません\",\n \"backfillWarning\": \"切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました\",\n \"windowControlFailed\": \"ウィンドウ操作に失敗しました: {{error}}\",\n \"officialBlockedByProxy\": \"ローカルルーティングモード中は公式プロバイダーに切り替えできません。ルーティング経由で公式 API にアクセスするとアカウントが停止される可能性があります。\",\n \"proxyOfficialWarning\": \"現在のプロバイダー {{name}} は公式です。ローカルルーティングを使用する前にサードパーティプロバイダーに切り替えてください。\"\n },\n \"confirm\": {\n \"deleteProvider\": \"プロバイダーを削除\",\n \"deleteProviderMessage\": \"プロバイダー「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。\",\n \"removeProvider\": \"プロバイダーを解除\",\n \"removeProviderMessage\": \"プロバイダー「{{name}}」を設定から解除してもよろしいですか?\\n\\n解除後、このプロバイダーは無効になりますが、設定データは CC Switch に保持されます。いつでも再追加できます。\",\n \"proxy\": {\n \"title\": \"ローカルルーティングの有効化\",\n \"message\": \"ローカルルーティングは上級機能です。有効にする前に、その仕組みを理解していることをご確認ください。\\n\\n適切な設定方法については、関連ドキュメントまたはプロバイダーにご相談ください。\",\n \"confirm\": \"理解しました、有効にする\"\n },\n \"failover\": {\n \"title\": \"フェイルオーバーの有効化\",\n \"message\": \"フェイルオーバーは上級機能です。有効にする前に、その仕組みを理解していることをご確認ください。\\n\\nフェイルオーバーキューでプロバイダーの優先順位を先に設定することをお勧めします。\",\n \"confirm\": \"理解しました、有効にする\"\n },\n \"unifyCodexHistory\": {\n \"title\": \"Codex セッション履歴を統一\",\n \"message\": \"オンにすると、公式サブスクリプションとサードパーティが同じセッション履歴リストを共有します。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content を相手のバックエンドが復号できず失敗する場合があります。\\n\\n既存の公式セッション履歴を共有リストへ移行することもできます(移行前に ~/.cc-switch/backups へ自動バックアップされ、オフにする際に復元を選択できます)。\",\n \"migrateExisting\": \"既存の公式セッション履歴も移行する\",\n \"confirm\": \"理解しました、オンにする\"\n },\n \"unifyCodexHistoryOff\": {\n \"title\": \"セッション履歴の統一をオフにする\",\n \"message\": \"オフにすると、公式サブスクリプションとサードパーティはそれぞれ独立した履歴リストに戻ります。オン期間中に作成されたセッションは提供元を判別できないため、サードパーティの履歴に残り、公式サブスクリプションからは見えなくなります。\",\n \"restoreBackup\": \"オンにした際に移行した公式セッションを公式履歴へ復元する(バックアップから正確に復元)\",\n \"confirm\": \"オフにする\"\n },\n \"usage\": {\n \"title\": \"使用量クエリの設定\",\n \"message\": \"使用量クエリにはカスタムスクリプトまたは API パラメータが必要です。プロバイダーから必要な情報を取得していることをご確認ください。\\n\\n設定方法が不明な場合は、プロバイダーのドキュメントを先にご確認ください。\",\n \"confirm\": \"理解しました、設定する\"\n },\n \"autoSync\": {\n \"title\": \"自動同期を有効にする\",\n \"message\": \"自動同期を有効にすると、データベースの変更ごとに WebDAV サーバーへ自動アップロードされます。\\n\\nネットワークトラフィックが大幅に増加する可能性があります。ネットワーク環境と WebDAV サービスが頻繁なデータ転送に対応できることをご確認ください。\",\n \"confirm\": \"理解しました、有効にする\"\n },\n \"commonConfig\": {\n \"title\": \"共通設定について\",\n \"message\": \"「共通設定スニペット」を使うと、プラグインや環境変数などの設定を異なるプロバイダー間で共有でき、切り替え時に失われることがありません。\\n\\n使い方:\\n① プロバイダーを編集 →「共通設定を編集」→「編集内容から抽出」\\n② 新規プロバイダー作成時に「共通設定を適用」をチェック(デフォルトでオン)\\n\\n新しいプラグインや Hook をインストールした場合は、共通設定を再抽出してください。\",\n \"confirm\": \"わかりました\"\n }\n },\n \"settings\": {\n \"title\": \"設定\",\n \"general\": \"一般\",\n \"tabGeneral\": \"一般\",\n \"tabAuth\": \"認証\",\n \"tabAdvanced\": \"詳細\",\n \"tabProxy\": \"ルーティング\",\n \"authCenter\": {\n \"title\": \"OAuth 認証センター\",\n \"description\": \"Claude Code で他のサブスクリプションをご利用いただけます。コンプライアンスリスクにご注意ください。\",\n \"beta\": \"Beta\",\n \"copilotDescription\": \"GitHub Copilot アカウントを管理します\",\n \"codexOauthDescription\": \"ChatGPT アカウントを管理します\"\n },\n \"advanced\": {\n \"configDir\": {\n \"title\": \"設定ディレクトリ\",\n \"description\": \"Claude、Codex、Gemini の設定保存パスを管理\"\n },\n \"proxy\": {\n \"title\": \"ローカルルーティング\",\n \"description\": \"ルーティングサービスの切り替え、ステータスとポート情報を表示\",\n \"enableFeature\": \"メインページにルーティング切り替えを表示\",\n \"enableFeatureDescription\": \"有効にすると、メインページ上部にルーティングとフェイルオーバーの切り替えが表示されます\",\n \"enableFailoverToggle\": \"メインページにフェイルオーバー切り替えを表示\",\n \"enableFailoverToggleDescription\": \"有効にすると、メインページ上部にフェイルオーバー切り替えが独立して表示されます\",\n \"running\": \"実行中\",\n \"stopped\": \"停止中\"\n },\n \"connectivityCheck\": {\n \"title\": \"接続チェック設定\",\n \"description\": \"プロバイダー接続チェックのタイムアウト、リトライ、低速判定しきい値を設定\"\n },\n \"failover\": {\n \"title\": \"自動フェイルオーバー\",\n \"description\": \"フェイルオーバーキューとサーキットブレーカー戦略を設定\"\n },\n \"pricing\": {\n \"title\": \"コスト計算\",\n \"description\": \"各モデルのトークン料金ルールを管理\"\n },\n \"globalProxy\": {\n \"title\": \"グローバル送信プロキシ\",\n \"description\": \"CC Switch が外部 API にアクセスする際のプロキシを設定\"\n },\n \"data\": {\n \"title\": \"データ管理\",\n \"description\": \"ローカル設定データのインポートとエクスポート\"\n },\n \"backup\": {\n \"title\": \"バックアップと復元\",\n \"description\": \"自動バックアップの管理、データベーススナップショットの表示と復元\"\n },\n \"cloudSync\": {\n \"title\": \"クラウド同期\",\n \"description\": \"WebDAV でデバイス間のデータを同期\"\n },\n \"rectifier\": {\n \"title\": \"整流器\",\n \"description\": \"API リクエストの互換性問題を自動修正\",\n \"enabled\": \"整流器を有効化\",\n \"enabledDescription\": \"マスタースイッチ、オフにするとすべての整流機能が無効になります\",\n \"requestGroup\": \"リクエスト整流\",\n \"responseGroup\": \"レスポンス整流\",\n \"thinkingSignature\": \"Thinking 署名整流\",\n \"thinkingSignatureDescription\": \"Anthropic タイプのプロバイダーが thinking 署名の非互換性や不正なリクエストエラーを返した場合、互換性のない thinking 関連ブロックを自動削除し、同じプロバイダーで 1 回リトライします\",\n \"thinkingBudget\": \"Thinking Budget 整流\",\n \"thinkingBudgetDescription\": \"Anthropic タイプのプロバイダーが budget_tokens 制約エラー(例: 1024 以上)を返した場合、thinking を enabled に正規化し、thinking 予算を 32000 に設定し、必要に応じて max_tokens を 64000 に引き上げて 1 回リトライします\",\n \"mediaFallback\": \"非対応画像フォールバック\",\n \"mediaFallbackDescription\": \"プロバイダーが画像入力に対応していない場合(テキストのみの能力が宣言されている、または上流が画像非対応エラーを返した場合)、画像ブロックを [Unsupported Image] マーカーに自動置換し、会話を中断させません\",\n \"mediaHeuristic\": \"テキスト専用モデルの事前判定\",\n \"mediaHeuristicDescription\": \"送信前に、内蔵の確認済みテキスト専用モデル登録に基づいて画像を除去します。オフにしても無効になるのはプロキシの登録ベース事前処理だけで、宣言済みテキスト専用モデルの処理と上流エラー時のフォールバックは残り、Codex モデルカタログの能力宣言は変わりません\"\n },\n \"optimizer\": {\n \"title\": \"Bedrock リクエストオプティマイザー\",\n \"description\": \"リクエスト送信前に Thinking と Cache の設定を自動最適化(Bedrock プロバイダーのみ有効)\",\n \"enabled\": \"オプティマイザーを有効化\",\n \"thinkingOptimizer\": \"Thinking 最適化\",\n \"thinkingOptimizerDescription\": \"Opus/Sonnet に Adaptive Thinking を自動的に有効化し、レガシーモデルに Extended Thinking を注入\",\n \"cacheInjection\": \"Cache 注入\",\n \"cacheInjectionDescription\": \"リクエストの重要な位置に標準の 5 分間 Cache ブレークポイントを自動注入し、重複トークンの課金を削減\"\n },\n \"logConfig\": {\n \"title\": \"ログ管理\",\n \"description\": \"ログ出力レベルを制御\",\n \"enabled\": \"ログを有効化\",\n \"enabledDescription\": \"マスタースイッチ、オフにするとすべてのログが無効になります\",\n \"level\": \"ログレベル\",\n \"levelDescription\": \"出力する最小ログレベルを設定\",\n \"levels\": {\n \"error\": \"エラー\",\n \"warn\": \"警告\",\n \"info\": \"情報\",\n \"debug\": \"デバッグ\",\n \"trace\": \"トレース\"\n },\n \"levelHint\": \"ログレベルの説明:\",\n \"levelDesc\": {\n \"error\": \"重大なエラーのみ\",\n \"warn\": \"エラー + 警告\",\n \"info\": \"一般的な操作情報(デフォルト)\",\n \"debug\": \"SSE ストリームとリクエスト/レスポンスを含む詳細情報\",\n \"trace\": \"すべてのログ、最も詳細\"\n }\n }\n },\n \"language\": \"言語\",\n \"languageHint\": \"切り替えるとすぐにプレビューされ、保存後に永続化されます。\",\n \"theme\": \"テーマ\",\n \"themeHint\": \"アプリのテーマを選択します。すぐに反映されます。\",\n \"themeLight\": \"ライト\",\n \"themeDark\": \"ダーク\",\n \"themeSystem\": \"システム\",\n \"importExport\": \"SQL インポート/エクスポート\",\n \"importExportHint\": \"移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします(インポートは CC Switch がエクスポートしたバックアップのみ対応)。\",\n \"exportConfig\": \"SQL バックアップをエクスポート\",\n \"selectConfigFile\": \"SQL ファイルを選択\",\n \"noFileSelected\": \"ファイルが選択されていません。\",\n \"import\": \"インポート\",\n \"importing\": \"インポート中...\",\n \"importSuccess\": \"インポート成功!\",\n \"importFailed\": \"インポート失敗\",\n \"syncLiveFailed\": \"インポートしましたが、現在のプロバイダーへの同期に失敗しました。手動で再選択してください。\",\n \"importPartialSuccess\": \"設定はインポートされましたが、現在のプロバイダーへの同期に失敗しました。\",\n \"importPartialHint\": \"ライブ設定を更新するため、もう一度プロバイダーを選択してください。\",\n \"configExported\": \"設定をエクスポートしました:\",\n \"exportFailed\": \"エクスポートに失敗しました\",\n \"selectFileFailed\": \"有効な SQL バックアップファイルを選択してください\",\n \"configCorrupted\": \"SQL ファイルが壊れているか形式が無効な可能性があります\",\n \"backupId\": \"バックアップ ID\",\n \"backupManager\": {\n \"title\": \"データベースバックアップ\",\n \"description\": \"以前の状態に復元するための自動データベーススナップショット\",\n \"empty\": \"バックアップはまだありません\",\n \"restore\": \"復元\",\n \"restoring\": \"復元中...\",\n \"confirmTitle\": \"バックアップの復元を確認\",\n \"confirmMessage\": \"このバックアップを復元すると現在のデータベースが上書きされます。安全バックアップが先に作成されます。\",\n \"restoreSuccess\": \"復元成功!安全バックアップが作成されました\",\n \"restoreFailed\": \"復元に失敗しました\",\n \"safetyBackupId\": \"安全バックアップID\",\n \"intervalLabel\": \"自動バックアップ間隔\",\n \"retainLabel\": \"バックアップ保持数\",\n \"intervalDisabled\": \"無効\",\n \"intervalHours\": \"{{hours}} 時間\",\n \"intervalDays\": \"{{days}} 日\",\n \"rename\": \"名前変更\",\n \"renameSuccess\": \"バックアップの名前を変更しました\",\n \"renameFailed\": \"名前変更に失敗しました\",\n \"namePlaceholder\": \"新しい名前を入力\",\n \"createBackup\": \"今すぐバックアップ\",\n \"creating\": \"バックアップ中...\",\n \"createSuccess\": \"バックアップが作成されました\",\n \"createFailed\": \"バックアップに失敗しました\",\n \"delete\": \"削除\",\n \"deleting\": \"削除中...\",\n \"deleteSuccess\": \"バックアップを削除しました\",\n \"deleteFailed\": \"削除に失敗しました\",\n \"deleteConfirmTitle\": \"バックアップの削除を確認\",\n \"deleteConfirmMessage\": \"このバックアップは完全に削除されます。この操作は取り消せません。\"\n },\n \"webdavSync\": {\n \"title\": \"WebDAV クラウド同期\",\n \"description\": \"WebDAV を使ってデバイス間でデータベースとスキル設定を同期します。\",\n \"baseUrl\": \"WebDAV サーバー URL\",\n \"baseUrlPlaceholder\": \"https://example.com/remote.php/dav/files/user\",\n \"username\": \"ユーザー名\",\n \"usernamePlaceholder\": \"メールアドレスまたはユーザー名\",\n \"password\": \"パスワード\",\n \"passwordPlaceholder\": \"アプリパスワード\",\n \"remoteRoot\": \"リモートルートディレクトリ\",\n \"profile\": \"同期プロファイル名\",\n \"autoSync\": \"自動同期\",\n \"autoSyncHint\": \"有効にすると、データベース変更のたびに WebDAV へ自動アップロードします。\",\n \"test\": \"接続テスト\",\n \"testing\": \"テスト中...\",\n \"testSuccess\": \"接続成功\",\n \"testFailed\": \"接続失敗:{{error}}\",\n \"save\": \"設定を保存\",\n \"saving\": \"保存中...\",\n \"saveFailed\": \"設定の保存に失敗しました:{{error}}\",\n \"upload\": \"クラウドにアップロード\",\n \"uploading\": \"アップロード中...\",\n \"uploadSuccess\": \"WebDAV にアップロードしました\",\n \"uploadFailed\": \"アップロードに失敗しました:{{error}}\",\n \"autoSyncFailedToast\": \"自動同期に失敗しました:{{error}}\",\n \"download\": \"クラウドからダウンロード\",\n \"downloading\": \"ダウンロード中...\",\n \"downloadSuccess\": \"WebDAV からダウンロード・復元しました\",\n \"downloadFailed\": \"ダウンロードに失敗しました:{{error}}\",\n \"lastSync\": \"前回の同期:{{time}}\",\n \"autoSyncLastErrorTitle\": \"前回の自動同期に失敗しました\",\n \"autoSyncLastErrorHint\": \"ネットワークまたは WebDAV 設定を確認してください。次回の変更時に自動再試行されます。\",\n \"missingUrl\": \"WebDAV サーバー URL を入力してください\",\n \"presets\": {\n \"label\": \"サービス\",\n \"jianguoyun\": \"坚果云\",\n \"jianguoyunHint\": \"坚果云の「セキュリティ設定」で「サードパーティアプリパスワード」を生成してください。ログインパスワードは使用しないでください。\",\n \"nextcloud\": \"Nextcloud\",\n \"nextcloudHint\": \"your-server を Nextcloud サーバーのアドレスに、USERNAME をユーザー名に置き換えてください。\",\n \"synology\": \"Synology NAS\",\n \"synologyHint\": \"Synology の「パッケージセンター」で WebDAV Server パッケージをインストール・有効化してください。\",\n \"custom\": \"カスタム\"\n },\n \"remoteRootDefault\": \"デフォルト: cc-switch-sync\",\n \"profileDefault\": \"デフォルト: default\",\n \"saveAndTestSuccess\": \"設定を保存しました。接続正常です\",\n \"saveAndTestFailed\": \"設定を保存しましたが、接続テストに失敗しました:{{error}}\",\n \"noRemoteData\": \"クラウドに同期データが見つかりません\",\n \"incompatibleVersion\": \"リモートデータに互換性がありません(プロトコル v{{protocolVersion}}、データベース {{dbCompatVersion}})。このクライアントは protocol v2 / db-v6 をサポートしています。\",\n \"unsaved\": \"未保存\",\n \"saved\": \"保存済み\",\n \"unsavedChanges\": \"先に設定を保存してください\",\n \"saveBeforeSync\": \"アップロード/ダウンロードを有効にするには、先に設定を保存してください。\",\n \"fetchingRemote\": \"リモート情報を取得中...\",\n \"fetchRemoteFailed\": \"リモート情報の取得に失敗しました。設定とネットワークを確認してください。\",\n \"confirmDownload\": {\n \"title\": \"クラウドから復元\",\n \"deviceName\": \"アップロード元\",\n \"createdAt\": \"アップロード日時\",\n \"path\": \"リモートパス\",\n \"dbCompat\": \"DB 互換レイヤー\",\n \"artifacts\": \"内容\",\n \"legacyNotice\": \"旧レイアウトのリモートパスを検出しました。復元後、次回のアップロードは新しい v2/db-v6 パスに書き込まれます。\",\n \"warning\": \"ローカルのすべてのデータとスキル設定が上書きされます\",\n \"confirm\": \"復元を実行\"\n },\n \"confirmUpload\": {\n \"title\": \"クラウドにアップロード\",\n \"content\": \"以下の内容を WebDAV サーバーに同期します:\",\n \"dbItem\": \"データベース(すべてのプロバイダー設定とデータ)\",\n \"skillsItem\": \"スキル(すべてのカスタムスキル)\",\n \"targetPath\": \"保存先パス\",\n \"existingData\": \"クラウドの既存データ\",\n \"deviceName\": \"アップロード元\",\n \"createdAt\": \"アップロード日時\",\n \"path\": \"リモートパス\",\n \"dbCompat\": \"DB 互換レイヤー\",\n \"warning\": \"リモートの既存同期データが上書きされます\",\n \"legacyNotice\": \"旧レイアウトのリモートデータを検出しました。今回のアップロードは新しい v2/db-v6 パスに書き込み、旧パスは上書きしません。\",\n \"confirm\": \"アップロードを実行\"\n }\n },\n \"syncType\": {\n \"label\": \"同期方式\",\n \"webdav\": \"WebDAV\",\n \"s3\": \"S3互換ストレージ\"\n },\n \"s3Sync\": {\n \"presets\": {\n \"label\": \"サービス\",\n \"awsS3\": \"AWS S3\",\n \"awsS3Hint\": \"IAM Access Keyを使用。s3:PutObject/GetObject/HeadObject権限が必要です。Region例: us-east-1, ap-northeast-1\",\n \"minio\": \"MinIO\",\n \"minioHint\": \"カスタムEndpointが必要です(例: http://minio.local:9000)。Regionは us-east-1 に設定可能\",\n \"r2\": \"Cloudflare R2\",\n \"r2Hint\": \"Endpoint: https://<account_id>.r2.cloudflarestorage.com、Regionは auto に設定\",\n \"oss\": \"Alibaba Cloud OSS\",\n \"ossHint\": \"Endpoint: https://oss-<region>.aliyuncs.com、Region例: cn-hangzhou, cn-shanghai\",\n \"cos\": \"Tencent Cloud COS\",\n \"cosHint\": \"Endpoint: https://cos.<region>.myqcloud.com、Region例: ap-guangzhou, ap-beijing\",\n \"obs\": \"Huawei OBS\",\n \"obsHint\": \"Endpoint: https://obs.<region>.myhuaweicloud.com、Region例: cn-north-4, cn-east-3\",\n \"custom\": \"S3互換サービス(カスタム)\",\n \"customHint\": \"任意のS3互換サービス。カスタムEndpointが必要です\"\n },\n \"region\": \"リージョン\",\n \"regionPlaceholder\": \"例: us-east-1\",\n \"bucket\": \"バケット\",\n \"bucketPlaceholder\": \"例: my-cc-switch-bucket\",\n \"accessKeyId\": \"Access Key ID\",\n \"accessKeyIdPlaceholder\": \"例: AKIA...\",\n \"secretAccessKey\": \"Secret Access Key\",\n \"secretAccessKeyPlaceholder\": \"既存のシークレットを保持する場合は変更しないでください\",\n \"endpoint\": \"エンドポイント(AWSは空欄可)\",\n \"endpointHint\": \"多くの S3 互換サービスでは必須です\",\n \"endpointPlaceholder\": \"例: https://s3-compatible.example.com\",\n \"remoteRoot\": \"リモートルートディレクトリ\",\n \"remoteRootDefault\": \"デフォルト: cc-switch-sync\",\n \"profile\": \"同期プロファイル名\",\n \"profileDefault\": \"デフォルト: default\",\n \"autoSync\": \"自動同期\",\n \"autoSyncHint\": \"有効にすると、データベース変更のたびに S3 へ自動アップロードします。\",\n \"enabled\": \"S3 同期を有効化\",\n \"enabledHint\": \"S3 のアップロード/ダウンロードと自動同期を有効にします。\",\n \"mutualExclusionTitle\": \"同期方式の切り替え\",\n \"mutualExclusionMessage\": \"S3同期を有効にすると、現在のWebDAV同期が無効になります。続行しますか?\",\n \"mutualExclusionMessageReverse\": \"WebDAV同期を有効にすると、現在のS3同期が無効になります。続行しますか?\",\n \"mutualExclusionFailed\": \"同期方式の切り替えに失敗しました:{{error}}\",\n \"testConnection\": \"接続テスト\",\n \"connectionOk\": \"S3接続成功\",\n \"test\": \"接続テスト\",\n \"testing\": \"テスト中...\",\n \"testSuccess\": \"S3 接続成功\",\n \"testFailed\": \"S3 接続失敗:{{error}}\",\n \"save\": \"設定を保存\",\n \"saving\": \"保存中...\",\n \"saveFailed\": \"設定の保存に失敗しました:{{error}}\",\n \"saveAndTestSuccess\": \"設定を保存しました。S3 接続正常です\",\n \"saveAndTestFailed\": \"設定を保存しましたが、S3 接続テストに失敗しました:{{error}}\",\n \"upload\": \"クラウドにアップロード\",\n \"uploading\": \"アップロード中...\",\n \"uploadSuccess\": \"S3 にアップロードしました\",\n \"uploadFailed\": \"S3 アップロードに失敗しました:{{error}}\",\n \"autoSyncFailedToast\": \"S3 自動同期に失敗しました:{{error}}\",\n \"download\": \"クラウドからダウンロード\",\n \"downloading\": \"ダウンロード中...\",\n \"downloadSuccess\": \"S3 からダウンロード・復元しました\",\n \"downloadFailed\": \"S3 ダウンロードに失敗しました:{{error}}\",\n \"lastSync\": \"前回の同期:{{time}}\",\n \"autoSyncLastErrorTitle\": \"前回の S3 自動同期に失敗しました\",\n \"autoSyncLastErrorHint\": \"ネットワークまたは S3 設定を確認してください。次回の変更時に自動再試行されます。\",\n \"missingBucket\": \"S3 バケットを入力してください\",\n \"noRemoteData\": \"S3 バケットに同期データが見つかりません\",\n \"incompatibleVersion\": \"リモートデータに互換性がありません(バージョン {{version}})。このクライアントは protocol v2 / db-v6 をサポートしています。\",\n \"unsaved\": \"未保存\",\n \"saved\": \"保存済み\",\n \"unsavedChanges\": \"先に設定を保存してください\",\n \"saveBeforeSync\": \"アップロード/ダウンロードを有効にするには、先に設定を保存してください。\",\n \"fetchingRemote\": \"リモート情報を取得中...\",\n \"fetchRemoteFailed\": \"リモート情報の取得に失敗しました。設定とネットワークを確認してください。\",\n \"notConfigured\": \"S3同期が設定されていません\",\n \"disabled\": \"S3同期が無効です\",\n \"confirmDownload\": {\n \"title\": \"S3 から復元\",\n \"deviceName\": \"アップロード元\",\n \"createdAt\": \"アップロード日時\",\n \"artifacts\": \"内容\",\n \"warning\": \"ローカルのすべてのデータとスキル設定が上書きされます\",\n \"confirm\": \"復元を実行\"\n },\n \"confirmUpload\": {\n \"title\": \"S3 にアップロード\",\n \"content\": \"以下の内容を S3 バケットに同期します:\",\n \"dbItem\": \"データベース(すべてのプロバイダー設定とデータ)\",\n \"skillsItem\": \"スキル(すべてのカスタムスキル)\",\n \"targetPath\": \"保存先オブジェクトパス\",\n \"existingData\": \"クラウドの既存データ\",\n \"deviceName\": \"アップロード元\",\n \"createdAt\": \"アップロード日時\",\n \"warning\": \"この S3 パスにある既存の同期データが上書きされます\",\n \"confirm\": \"アップロードを実行\"\n }\n },\n \"autoReload\": \"データを更新しました\",\n \"languageOptionChinese\": \"简体中文\",\n \"languageOptionTraditionalChinese\": \"繁體中文\",\n \"languageOptionEnglish\": \"English\",\n \"languageOptionJapanese\": \"日本語\",\n \"windowBehavior\": \"ウィンドウ動作\",\n \"windowBehaviorHint\": \"最小化動作や Claude プラグイン連携を設定します。\",\n \"launchOnStartup\": \"起動時に自動実行\",\n \"launchOnStartupDescription\": \"システム起動時に CC Switch を自動起動します\",\n \"silentStartup\": \"サイレント起動\",\n \"silentStartupDescription\": \"起動時にメインウィンドウを表示せず、トレイのみで起動\",\n \"autoLaunchFailed\": \"自動起動の設定に失敗しました\",\n \"minimizeToTray\": \"閉じるときトレイへ最小化\",\n \"minimizeToTrayDescription\": \"チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。\",\n \"useAppWindowControls\": \"アプリ内ウィンドウボタンを有効化\",\n \"useAppWindowControlsDescription\": \"有効にすると、アプリヘッダーに最小化・最大化/復元・閉じるボタンを表示します。\",\n \"enableClaudePluginIntegration\": \"Claude Code 拡張に適用\",\n \"enableClaudePluginIntegrationDescription\": \"オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します\",\n \"skipClaudeOnboarding\": \"Claude Code の初回確認をスキップ\",\n \"skipClaudeOnboardingDescription\": \"オンにすると Claude Code の初回インストール確認をスキップします\",\n \"codexAuth\": \"Codex アプリ拡張\",\n \"preserveCodexOfficialAuthOnSwitch\": \"直接切替時に公式ログインを保持\",\n \"preserveCodexOfficialAuthOnSwitchDescription\": \"ローカルルーティングが無効な場合のサードパーティ切替を制御します。ルーティング接管中は常に Codex の公式ログインを保持します。\",\n \"unifyCodexSessionHistory\": \"Codex セッション履歴を統一\",\n \"unifyCodexSessionHistoryDescription\": \"オンにすると、公式サブスクリプションも共有の custom プロバイダー ID で動作し、公式とサードパーティのセッションが同じ履歴リストに表示されます。既存の公式セッションの移行も選択できます(移行前に自動バックアップ)。オフにする際はバックアップから復元できます。注意:プロバイダーをまたいで古いセッションを再開すると、encrypted_content の推論内容を相手のバックエンドが復号できず、再開に失敗する場合があります。\",\n \"unifyCodexHistoryRestoreCompleted\": \"バックアップから公式セッション履歴を復元しました(セッションファイル {{files}} 件、インデックス {{rows}} 行)\",\n \"unifyCodexHistoryRestoreFailed\": \"公式セッション履歴の復元に失敗しました。もう一度お試しください\",\n \"unifyCodexHistoryRestoreNothing\": \"現在の Codex ディレクトリに復元可能な移行バックアップはありません\",\n \"unifyCodexHistoryRestoreSkippedToggleOn\": \"統一セッション履歴が再度有効化されたため、復元をスキップしました\",\n \"appVisibility\": {\n \"title\": \"ホームページ表示\",\n \"description\": \"ホームページに表示するアプリを選択\",\n \"claudeDesc\": \"Anthropic Claude Code CLI\",\n \"codexDesc\": \"OpenAI Codex CLI\",\n \"geminiDesc\": \"Google Gemini CLI\",\n \"opencodeDesc\": \"OpenCode CLI\",\n \"showProfileSwitcher\": \"プロジェクト切り替えを表示\",\n \"showProfileSwitcherDescription\": \"メイン画面上部にプロジェクト切り替えを表示します\"\n },\n \"skillStorage\": {\n \"title\": \"スキル保存場所\",\n \"description\": \"CC Switch がスキルのマスターコピーを保存するディレクトリを選択します\",\n \"ccSwitch\": \"CC Switch\",\n \"unified\": \"~/.agents/skills\",\n \"ccSwitchHint\": \"スキルは ~/.cc-switch/skills/ に保存され、シンボリックリンクまたはコピーで各アプリに同期されます。\",\n \"unifiedHint\": \"スキルは ~/.agents/skills/ に保存されます(Agent Skills オープン標準)。対応ツール(Claude Code、Codex、Gemini CLI など)はこのディレクトリのスキルを直接検出します。\",\n \"confirmTitle\": \"スキル保存場所の移行\",\n \"confirmMessage\": \"{{count}} 個のスキルを新しい場所に移動します。続行しますか?\",\n \"migrationSuccess\": \"{{count}} 個のスキルを移行しました\",\n \"migrationPartial\": \"{{migrated}} 個のスキルを移行、{{errors}} 個のエラー。詳細はログを確認してください。\"\n },\n \"skillSync\": {\n \"title\": \"スキル同期方式\",\n \"description\": \"スキルファイルの同期方法を選択\",\n \"symlink\": \"シンボリックリンク\",\n \"copy\": \"ファイルコピー\",\n \"symlinkHint\": \"シンボリックリンクはディスク容量を節約し、リアルタイム同期を有効にします。注意:Windowsでは管理者権限または開発者モードが必要な場合があります\"\n },\n \"terminal\": {\n \"title\": \"優先ターミナル\",\n \"description\": \"ターミナルボタンをクリックした時に使用するターミナルアプリを選択\",\n \"fallbackHint\": \"選択したターミナルが利用できない場合、システムのデフォルトが使用されます\",\n \"options\": {\n \"macos\": {\n \"terminal\": \"Terminal.app\",\n \"iterm2\": \"iTerm2\",\n \"alacritty\": \"Alacritty\",\n \"kitty\": \"Kitty\",\n \"ghostty\": \"Ghostty\",\n \"wezterm\": \"WezTerm\",\n \"kaku\": \"Kaku\",\n \"warp\": \"Warp\"\n },\n \"windows\": {\n \"cmd\": \"コマンドプロンプト\",\n \"powershell\": \"PowerShell\",\n \"wt\": \"Windows Terminal\"\n },\n \"linux\": {\n \"gnomeTerminal\": \"GNOME Terminal\",\n \"konsole\": \"Konsole\",\n \"xfce4Terminal\": \"Xfce4 Terminal\",\n \"alacritty\": \"Alacritty\",\n \"kitty\": \"Kitty\",\n \"ghostty\": \"Ghostty\"\n }\n }\n },\n \"configDirectoryOverride\": \"設定ディレクトリの上書き(詳細)\",\n \"configDirectoryDescription\": \"WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。\",\n \"appConfigDir\": \"CC Switch 設定ディレクトリ\",\n \"appConfigDirDescription\": \"CC Switch の保存場所をカスタマイズします(クラウド同期フォルダを指定すると設定を同期できます)\",\n \"browsePlaceholderApp\": \"例: C:\\\\\\\\Users\\\\\\\\Administrator\\\\\\\\.cc-switch\",\n \"claudeConfigDir\": \"Claude Code 設定ディレクトリ\",\n \"claudeConfigDirDescription\": \"Claude の設定ディレクトリ(settings.json)を上書きします。カスタムディレクトリでは .claude.json(MCP)をその中に保存し、既定の ~/.claude では Claude Code の既定どおり ~/.claude.json を使います。\",\n \"codexConfigDir\": \"Codex 設定ディレクトリ\",\n \"codexConfigDirDescription\": \"Codex の設定ディレクトリを上書きします。\",\n \"geminiConfigDir\": \"Gemini 設定ディレクトリ\",\n \"geminiConfigDirDescription\": \"Gemini の設定ディレクトリ(.env)を上書きします。\",\n \"opencodeConfigDir\": \"OpenCode 設定ディレクトリ\",\n \"opencodeConfigDirDescription\": \"OpenCode の設定ディレクトリ(opencode.json)を上書きします。\",\n \"openclawConfigDir\": \"OpenClaw 設定ディレクトリ\",\n \"openclawConfigDirDescription\": \"OpenClaw の設定ディレクトリ(openclaw.json)を上書きします。\",\n \"hermesConfigDir\": \"Hermes 設定ディレクトリ\",\n \"hermesConfigDirDescription\": \"Hermes の設定ディレクトリ(config.yaml)を上書きします。\",\n \"browsePlaceholderClaude\": \"例: /home/<your-username>/.claude\",\n \"browsePlaceholderCodex\": \"例: /home/<your-username>/.codex\",\n \"browsePlaceholderGemini\": \"例: /home/<your-username>/.gemini\",\n \"browsePlaceholderOpencode\": \"例: /home/<your-username>/.config/opencode\",\n \"browsePlaceholderOpenclaw\": \"例: /home/<your-username>/.openclaw\",\n \"browsePlaceholderHermes\": \"例: /home/<your-username>/.hermes\",\n \"browseDirectory\": \"ディレクトリを選択\",\n \"resetDefault\": \"デフォルトに戻す(保存後に反映)\",\n \"checkForUpdates\": \"アップデートを確認\",\n \"updateTo\": \"v{{version}} に更新\",\n \"updating\": \"更新中...\",\n \"checking\": \"確認中...\",\n \"upToDate\": \"最新バージョンです\",\n \"aboutHint\": \"バージョン情報と更新状況を表示します。\",\n \"portableMode\": \"ポータブルモード: 更新は手動ダウンロードが必要です。\",\n \"updateAvailable\": \"新しいバージョンがあります: {{version}}\",\n \"updateBadge\": \"更新あり\",\n \"updateFailed\": \"更新のインストールに失敗しました。ダウンロードページを開こうとしました。\",\n \"checkUpdateFailed\": \"更新の確認に失敗しました。時間をおいて再試行してください。\",\n \"openReleaseNotesFailed\": \"リリースノートの表示に失敗しました\",\n \"releaseNotes\": \"リリースノート\",\n \"viewReleaseNotes\": \"このバージョンのリリースノートを見る\",\n \"viewCurrentReleaseNotes\": \"現在のバージョンのリリースノートを見る\",\n \"officialWebsite\": \"公式サイト\",\n \"github\": \"GitHub\",\n \"manualInstallCommands\": \"手動インストールコマンド\",\n \"oneClickInstallHint\": \"Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes をインストールまたは更新\",\n \"localEnvCheck\": \"ローカル環境チェック\",\n \"updateAllTools\": \"すべて更新({{count}})\",\n \"currentVersion\": \"現在のバージョン\",\n \"latestVersion\": \"最新バージョン\",\n \"updateAvailableShort\": \"更新あり\",\n \"toolInstall\": \"インストール\",\n \"toolUpdate\": \"更新\",\n \"toolReady\": \"準備完了\",\n \"toolActionDone\": \"{{count}} 個のツールの{{action}}が完了しました\",\n \"toolActionPartial\": \"{{succeeded}} 個成功、{{failed}} 個失敗({{action}})\",\n \"toolActionFailed\": \"インストール/更新コマンドの実行に失敗しました\",\n \"toolNotRunnable\": \"インストール済みですが実行できません(バージョン未検出)\",\n \"toolActionVersionUnchangedTitle\": \"バージョンは変わりませんでした\",\n \"toolActionVersionUnchanged\": \"現在も {{version}}(最新: {{latest}})です。アップデーターが成功を報告しても実際には更新されていない可能性があります。\",\n \"toolActionInstalledNotRunnable\": \"インストールされましたが、現在の環境では実行できません。確認してください\",\n \"installedNotRunnable\": \"インストール済み・実行不可\",\n \"toolCheckEnv\": \"実行環境を確認\",\n \"toolDiagnose\": \"インストールを診断\",\n \"toolDiagnosing\": \"診断中…\",\n \"toolConflictTitle\": \"複数のインストールを検出\",\n \"toolConflictHint\": \"コマンドラインは「既定」のものを使用します。更新が別の場所に適用された可能性があります。\",\n \"toolConflictDefault\": \"既定\",\n \"toolConflictNotRunnable\": \"実行不可\",\n \"toolDiagnoseNoConflict\": \"競合するインストールは見つかりませんでした\",\n \"toolDiagnoseFailed\": \"診断に失敗しました\",\n \"toolUpgradeConfirmTitle\": \"アップグレード先の確認\",\n \"toolUpgradeConfirmHint\": \"複数のインストールを検出しました。今回のアップグレードはすべてを更新するわけではありません。実際に作用する箇所は下記の各項目をご確認ください。\",\n \"toolUpgradeWillRun\": \"実行内容:\",\n \"toolUpgradeConfirmBtn\": \"アップグレードを実行\",\n \"toolUpgradeUnanchoredHint\": \"コマンドラインが実際に使用する箇所を特定できません。既定のアップグレードコマンドを実行します(PATH 上の最初の npm にインストールされる場合があります)。\",\n \"envBadge\": {\n \"wsl\": \"WSL\",\n \"windows\": \"Win\",\n \"macos\": \"macOS\",\n \"linux\": \"Linux\"\n },\n \"wslShell\": \"Shell\",\n \"wslShellFlag\": \"フラグ\",\n \"installCommandsCopied\": \"インストールコマンドをコピーしました\",\n \"installCommandsCopyFailed\": \"コピーに失敗しました。手動でコピーしてください。\",\n \"importFailedError\": \"設定のインポートに失敗しました: {{message}}\",\n \"exportFailedError\": \"設定のエクスポートに失敗しました:\",\n \"restartRequired\": \"再起動が必要です\",\n \"restartRequiredMessage\": \"CC Switch の設定ディレクトリを変更すると再起動が必要です。今すぐ再起動しますか?\",\n \"restartNow\": \"今すぐ再起動\",\n \"restartLater\": \"後で再起動\",\n \"restartFailed\": \"アプリの再起動に失敗しました。手動で閉じて再度開いてください。\",\n \"devModeRestartHint\": \"開発モードでは自動再起動をサポートしていません。手動で再起動してください。\",\n \"saving\": \"保存中...\",\n \"globalProxy\": {\n \"label\": \"グローバルプロキシ\",\n \"hint\": \"すべてのリクエスト(API、Skills ダウンロードなど)をプロキシ経由で送信します。ローカルルーティング有効時、アプリのリクエストもこのプロキシを経由します。空欄で直接接続。\",\n \"username\": \"ユーザー名(任意)\",\n \"password\": \"パスワード(任意)\",\n \"test\": \"接続テスト\",\n \"scan\": \"ローカルプロキシをスキャン\",\n \"clear\": \"クリア\",\n \"scanFailed\": \"スキャンに失敗しました: {{error}}\",\n \"saved\": \"ルーティング設定を保存しました\",\n \"saveFailed\": \"保存に失敗しました: {{error}}\",\n \"testSuccess\": \"接続成功!遅延 {{latency}}ms\",\n \"testFailed\": \"接続に失敗しました: {{error}}\",\n \"pricingDefaultsTitle\": \"課金のデフォルト設定\",\n \"pricingDefaultsDescription\": \"アプリごとのデフォルト倍率と課金モードを設定します。\",\n \"pricingAppLabel\": \"アプリ\",\n \"defaultCostMultiplierLabel\": \"デフォルト倍率\",\n \"defaultCostMultiplierHint\": \"コスト計算用の倍率(小数対応)。\",\n \"pricingModelSourceLabel\": \"課金モード\",\n \"pricingModelSourceRequest\": \"リクエストモデル\",\n \"pricingModelSourceResponse\": \"レスポンスモデル\",\n \"pricingSave\": \"課金設定を保存\",\n \"pricingSaved\": \"課金設定を保存しました\",\n \"pricingSaveFailed\": \"課金設定の保存に失敗しました: {{error}}\",\n \"pricingLoadFailed\": \"課金設定の読み込みに失敗しました: {{error}}\",\n \"defaultCostMultiplierRequired\": \"デフォルト倍率は必須です\",\n \"defaultCostMultiplierInvalid\": \"デフォルト倍率の形式が正しくありません\"\n },\n \"saveFailedGeneric\": \"保存に失敗しました。もう一度お試しください\"\n },\n \"apps\": {\n \"claude\": \"Claude\",\n \"claudeCode\": \"Claude Code\",\n \"claudeDesktop\": \"Claude Desktop\",\n \"claude-desktop\": \"Claude Desktop\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\",\n \"openclaw\": \"OpenClaw\",\n \"hermes\": \"Hermes\",\n \"grok\": \"Grok Build\"\n },\n \"sessionManager\": {\n \"title\": \"セッション管理\",\n \"subtitle\": \"Claude Code / Codex / OpenCode / OpenClaw / Hermes / Gemini CLI のセッションを管理\",\n \"searchPlaceholder\": \"内容・ディレクトリ・ID で検索\",\n \"searchSessions\": \"セッションを検索\",\n \"providerFilterAll\": \"すべて\",\n \"providerFilterTooltip\": \"プロバイダーフィルター\",\n \"sessionList\": \"セッション一覧\",\n \"viewModeTooltip\": \"表示方式\",\n \"viewModeFlat\": \"一覧\",\n \"viewModeGrouped\": \"分類\",\n \"unknownDirectory\": \"不明なディレクトリ\",\n \"toggleProviderGroup\": \"{{provider}} プロバイダーグループを開閉\",\n \"toggleDirectoryGroup\": \"{{directory}} ディレクトリグループを開閉\",\n \"collapseAllGroups\": \"すべて折りたたむ\",\n \"selectProviderGroupForBatch\": \"{{provider}} プロバイダーグループ内のセッションを選択\",\n \"selectDirectoryGroupForBatch\": \"{{directory}} ディレクトリグループ内のセッションを選択\",\n \"manageBatchTooltip\": \"一括管理に入る\",\n \"exitBatchModeTooltip\": \"一括管理を終了\",\n \"batchModeHint\": \"削除するセッションを選択\",\n \"selectForBatch\": \"セッションを選択\",\n \"selectedCount\": \"{{count}} 件を選択中\",\n \"selectAllFiltered\": \"一覧を全選択\",\n \"clearFilteredSelection\": \"全選択を解除\",\n \"clearSelection\": \"クリア\",\n \"deleteSelected\": \"削除\",\n \"batchDeleting\": \"削除中...\",\n \"loadingSessions\": \"セッションを読み込み中...\",\n \"noSessions\": \"セッションが見つかりません\",\n \"selectSession\": \"セッションを選択してください\",\n \"noSummary\": \"概要なし\",\n \"lastActive\": \"最終アクティブ\",\n \"projectDir\": \"プロジェクトディレクトリ\",\n \"sourcePath\": \"元ファイル\",\n \"copyResumeCommand\": \"再開コマンドをコピー\",\n \"resumeCommandCopied\": \"再開コマンドをコピーしました\",\n \"openInTerminal\": \"ターミナルで再開\",\n \"terminalTargetTerminal\": \"Terminal\",\n \"terminalTargetKitty\": \"kitty\",\n \"terminalTargetCopy\": \"コピーのみ\",\n \"terminalLaunched\": \"ターミナルを起動しました\",\n \"openFailed\": \"ターミナルの起動に失敗しました\",\n \"resumeFallbackCopied\": \"再開コマンドをコピーしました(手動で実行してください)\",\n \"copyProjectDir\": \"ディレクトリをコピー\",\n \"projectDirCopied\": \"ディレクトリをコピーしました\",\n \"copySourcePath\": \"元ファイルをコピー\",\n \"sourcePathCopied\": \"元ファイルをコピーしました\",\n \"delete\": \"セッションを削除\",\n \"deleting\": \"削除中...\",\n \"deleteTooltip\": \"このローカルセッション記録を完全に削除します\",\n \"deleteConfirmTitle\": \"セッションを削除\",\n \"deleteConfirmMessage\": \"ローカルセッション「{{title}}」を完全に削除します\\nSession ID: {{sessionId}}\\n\\nこの操作は元に戻せません。\",\n \"deleteConfirmAction\": \"セッションを削除\",\n \"sessionDeleted\": \"セッションを削除しました\",\n \"deleteFailed\": \"セッションの削除に失敗しました: {{error}}\",\n \"batchDeleteConfirmTitle\": \"選択したセッションを削除\",\n \"batchDeleteConfirmMessage\": \"選択した {{count}} 件のローカルセッションを完全に削除します。\\n\\nこの操作は元に戻せません。\",\n \"batchDeleteConfirmAction\": \"選択した項目を削除\",\n \"batchDeleteSuccess\": \"{{count}} 件のセッションを削除しました\",\n \"batchDeleteFailed\": \"{{failed}} 件のセッションを削除できませんでした\",\n \"batchDeleteRequestFailed\": \"一括削除に失敗しました。しばらくしてから再試行してください。\",\n \"loadingMessages\": \"内容を読み込み中...\",\n \"emptySession\": \"表示できる内容がありません\",\n \"clickToCopyPath\": \"クリックしてパスをコピー\",\n \"tocTitle\": \"目次\",\n \"justNow\": \"たった今\",\n \"minutesAgo\": \"{{count}}分前\",\n \"hoursAgo\": \"{{count}}時間前\",\n \"daysAgo\": \"{{count}}日前\",\n \"roleUser\": \"ユーザー\",\n \"roleSystem\": \"システム\",\n \"roleTool\": \"ツール\",\n \"resume\": \"セッションを再開\",\n \"resumeTooltip\": \"ターミナルでこのセッションを再開\",\n \"noResumeCommand\": \"このセッションは再開できません\",\n \"copyCommand\": \"コマンドをコピー\",\n \"copyMessage\": \"メッセージをコピー\",\n \"messageCopied\": \"メッセージがコピーされました\",\n \"conversationHistory\": \"会話履歴\",\n \"expandContent\": \"全文を表示\",\n \"collapseContent\": \"折りたたむ\"\n },\n \"console\": {\n \"providerSwitchReceived\": \"プロバイダー切り替えイベントを受信:\",\n \"setupListenerFailed\": \"プロバイダー切り替えリスナーの設定に失敗:\",\n \"updateProviderFailed\": \"プロバイダー更新に失敗:\",\n \"autoImportFailed\": \"デフォルト設定の自動インポートに失敗:\",\n \"openLinkFailed\": \"リンクを開けませんでした:\",\n \"getVersionFailed\": \"バージョン情報の取得に失敗:\",\n \"loadSettingsFailed\": \"設定の読み込みに失敗:\",\n \"getConfigPathFailed\": \"設定パスの取得に失敗:\",\n \"getConfigDirFailed\": \"設定ディレクトリの取得に失敗:\",\n \"detectPortableFailed\": \"ポータブルモードの検出に失敗:\",\n \"saveSettingsFailed\": \"設定の保存に失敗:\",\n \"updateFailed\": \"更新に失敗:\",\n \"checkUpdateFailed\": \"更新確認に失敗:\",\n \"openConfigFolderFailed\": \"設定フォルダを開けませんでした:\",\n \"selectConfigDirFailed\": \"設定ディレクトリの選択に失敗:\",\n \"getDefaultConfigDirFailed\": \"デフォルト設定ディレクトリの取得に失敗:\",\n \"openReleaseNotesFailed\": \"リリースノートを開けませんでした:\"\n },\n \"providerForm\": {\n \"supplierName\": \"プロバイダー名\",\n \"supplierNameRequired\": \"プロバイダー名 *\",\n \"supplierNamePlaceholder\": \"例: Anthropic Official\",\n \"websiteUrl\": \"Web サイト URL\",\n \"websiteUrlPlaceholder\": \"https://example.com(任意)\",\n \"apiEndpoint\": \"API エンドポイント\",\n \"apiEndpointPlaceholder\": \"https://your-api-endpoint.com\",\n \"codexApiEndpointPlaceholder\": \"https://your-api-endpoint.com/v1\",\n \"manageAndTest\": \"管理・テスト\",\n \"configContent\": \"設定内容\",\n \"officialNoApiKey\": \"公式ログインは API Key 不要です。そのまま保存できます\",\n \"codexOfficialNoApiKey\": \"公式は API Key 不要です。そのまま保存してください\",\n \"codexApiKeyAutoFill\": \"ここに入力すれば auth.json も自動で埋まります\",\n \"apiKeyAutoFill\": \"ここに入力すれば下の設定も自動で埋まります\",\n \"cnOfficialApiKeyHint\": \"💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです\",\n \"aggregatorApiKeyHint\": \"💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです\",\n \"thirdPartyApiKeyHint\": \"💡 API Key のみ入力すれば OK。エンドポイントはプリセット済みです\",\n \"customApiKeyHint\": \"💡 カスタム設定では必要な項目をすべて手動で入力してください\",\n \"omoHint\": \"💡 OMO 設定は Agent のモデル割り当てを管理し、oh-my-openagent.jsonc / oh-my-opencode.jsonc の両方に対応します\",\n \"officialHint\": \"💡 公式プロバイダーはブラウザログインで、API Key は不要です\",\n \"getApiKey\": \"API Key を取得\",\n \"partnerPromotion\": {\n \"sudocode\": \"SudoCode の1つのキーで、Claude Code と Claude Desktop では Claude Opus 4.8、Codex では GPT-5.6 を利用できます。登録後 QQ グループ 726213516 に参加し、管理者へ連絡すると CNY ¥10 のトライアルクレジットを受け取れます。\",\n \"code0\": \"code0.ai は開発者向けの AI コーディングサービスプラットフォームで、Claude Code、Codex、Gemini に対応。CC Switch ユーザー限定特典:公式サイトからサポートに連絡してテストクレジットを受け取れます!\",\n \"nekocode\": \"NekoCode は Claude や Codex などの AI モデルに対応した、安定・高効率で信頼性の高い API 中継サービスを提供します。明瞭な従量課金制。CC Switch ユーザー限定 10%オフ:上のリンクから登録し、チャージ時にクーポンコード cc-switch を入力すると 10%オフ!\",\n \"packycode\": \"PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \\\"cc-switch\\\" を入力すると 10% オフ\",\n \"apikeyfun\": \"APIKEY.FUN は CC Switch ユーザー向けに特別優待を提供しています。専用リンクから登録すると、最大でチャージ永久 5% オフを受けられます。\",\n \"apinebula\": \"APINEBULA は CC Switch ユーザー向けに特別割引を提供しています。専用リンクから登録し、チャージ時にプロモコード「ccswitch」を入力すると、さらに 10% OFF の割引が適用されます。\",\n \"atlascloud\": \"Atlas Cloud は、1 つの API で動画・画像生成や LLM を利用できる全モーダル対応の AI 推論プラットフォームです。より低コストで API を利用できる新しい「コーディングプラン」プロモーションをご確認ください。\",\n \"patewayai\": \"PatewayAI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録すると $3 のクレジットがもらえます。\",\n \"claudeapi\": \"ClaudeAPI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録するとテストクレジットを受け取ることができます。\",\n \"claudecn\": \"ClaudeCN は登録企業が運営するエンタープライズグレードの AI ゲートウェイプラットフォームで、企業調達プロセスをサポートし、法人支払い、契約、コンプライアンス保証を提供します。\",\n \"runapi\": \"RunAPI は CC Switch ユーザーに特別な特典を提供しています。このリンクから登録しカスタマーサポートにご連絡いただくと、¥14 の無料クレジットを受け取ることができます。\",\n \"minimax_cn\": \"MiniMax Coding Plan 特別価格、Starter ¥9.9 から\",\n \"minimax_en\": \"MiniMax Coding Plan Black Friday、Starter が月額 $2(80% OFF)\",\n \"opencode_go\": \"このリンクから OpenCode Go を購読すると、初月はわずか $5、さらに $5 分のクレジットがもらえます!\",\n \"dmxapi\": \"Claude Code 専用モデル 66% OFF 実施中!\",\n \"cubence\": \"Cubence は CC Switch の公式パートナーです。登録後チャージ時に \\\"CCSWITCH\\\" を入力すると、毎回 10% オフ\",\n \"aigocode\": \"AIGoCode は CC Switch の公式パートナーです。このリンクから登録すると、初回チャージ時に 10% のボーナスクレジットがもらえます!\",\n \"rightcode\": \"RightCode は CC Switch の公式パートナーです。このリンクから登録すると、毎回のチャージに 5% のボーナスクレジットがもらえます!\",\n \"aicodemirror\": \"AICodeMirror は CC Switch の公式パートナーです。このリンクから登録すると20%オフ!\",\n \"crazyrouter\": \"CrazyRouter は CC Switch ユーザー向けに特別ボーナスを提供しています。初回チャージで 30% の追加クレジットがもらえます!\",\n \"sssaicode\": \"SSAI Code は CC Switch ユーザー向けに特別ボーナスを提供しています。チャージごとに $10 の追加クレジットがもらえます!\",\n \"siliconflow\": \"SiliconFlow は CC Switch の公式パートナーです\",\n \"ucloud\": \"Compshare は CC Switch ユーザー向けに特別ボーナスを提供しています。このリンクから登録すると、5元のプラットフォーム体験クレジットがもらえます!\",\n \"micu\": \"Micu は CC Switch の公式パートナーです\",\n \"etok\": \"公式サイトで ETok コミュニティに参加し、プランを購読してください。\",\n \"shengsuanyun\": \"胜算云はCC Switchユーザーに特別特典を提供しています。このリンクから登録すると、10元分の無料クレジットと初回チャージ10%ボーナスがもらえます!\",\n \"doubaoseed\": \"豆包大モデルは CC Switch ユーザーに専属特典を提供しています:このリンクから登録すると、豆包全テキストモデル50万トークンの推論クレジットおよびSeedance2.0の500万トークン無料クレジットがもらえます。\",\n \"volcengine_agentplan\": \"火山方舟 Coding Plan は CC Switch ユーザーに専属特典を提供しています:このリンクから方舟 Coding Plan を購読すると、新規のお客様は最初の 2 か月が 75% オフ、さらに専用招待コード 6J6FV5N2 で 5% オフが加算され、月額 9.4 元から!\",\n \"byteplus\": \"このリンクから登録すると、各モデルごとに50万トークンの無料推論クォータがもらえます。\",\n \"ccsub\": \"CCSub は安定・低価格の AI API リレーサービスです。Claude Code の公式サブスクリプションの代替として、1つのキーで全モデルを公式比約1/3のコストで利用できます。\",\n \"subrouter\": \"SubRouter は、1つのキーで複数のモデルとプロバイダーにアクセスできる AI 中継アグリゲーターです。\",\n \"qiniu\": \"Qiniu Cloud(七牛云)はマルチプロトコル対応の AI 中継プラットフォームです。1つのキーでネイティブの Claude・GPT・Gemini モデルを利用でき、Anthropic/OpenAI/Gemini の各 API に対応します。\",\n \"fenno\": \"Fenno.ai は安定・高効率な API 中継サービスで、幅広い主要モデルを利用できます。CC Switch 利用者限定特典:このリンクから 9.9 元(150 ドル相当)の Coding Plan を購入できます!\",\n \"zetaapi\": \"ZetaAPI はモデルを水増し・劣化させず、公式価格の 35% から利用できます。CC Switch 利用者限定特典:このリンクから登録し、初回チャージ時にプロモコード CC-SWITCH を使うと初回チャージ 10% オフ!\",\n \"teamorouter\": \"TeamoRouter はエンタープライズグレードの Agentic LLM ゲートウェイで、料金は最大 90% オフ。CC Switch ユーザーに特別特典を提供しています:このリンクから新規登録すると初回チャージが 10% オフ!\",\n \"unity2\": \"Unity2.ai は CC Switch ユーザーに専属特典を提供しています:このリンクから登録すると $2 分のクレジット、公式グループ参加でさらに $10、最大 $12 の無料クレジットがもらえます!\"\n },\n \"presets\": {\n \"ucloud\": \"Compshare\",\n \"ucloudCoding\": \"Compshare Coding Plan\",\n \"shengsuanyun\": \"Shengsuanyun\",\n \"qiniu\": \"Qiniu\",\n \"openrouter\": \"OpenRouter\",\n \"deepseek\": \"DeepSeek\",\n \"together\": \"Together AI\"\n },\n \"parameterConfig\": \"パラメーター設定 - {{name}} *\",\n \"mainModel\": \"メインモデル(任意)\",\n \"mainModelPlaceholder\": \"例: GLM-4.6\",\n \"fastModel\": \"高速モデル(任意)\",\n \"fastModelPlaceholder\": \"例: GLM-4.5-Air\",\n \"modelHint\": \"💡 空欄ならプロバイダーのデフォルトモデルを使用します\",\n \"apiHint\": \"💡 Claude API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください\",\n \"apiHintOAI\": \"💡 OpenAI Chat Completions 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください\",\n \"apiHintGeminiNative\": \"💡 Gemini Native では https://generativelanguage.googleapis.com または https://generativelanguage.googleapis.com/v1beta のような base URL を推奨します。プロキシが models/*:generateContent を自動補完します\",\n \"codexApiHint\": \"💡 OpenAI Response 互換のサービスエンドポイントを入力してください\",\n \"fillSupplierName\": \"プロバイダー名を入力してください\",\n \"fillConfigContent\": \"設定内容を入力してください\",\n \"fillParameter\": \"{{label}} を入力してください\",\n \"fillTemplateValue\": \"{{label}} を入力してください\",\n \"endpointRequired\": \"公式以外は API エンドポイントが必須です\",\n \"apiKeyRequired\": \"公式以外は API Key が必須です\",\n \"softValidation\": {\n \"title\": \"設定に以下の問題があります\",\n \"hint\": \"それでも保存しますか?保存後、このプロバイダーへの切り替えが失敗する可能性がありますが、後から補完できます。\",\n \"saveAnyway\": \"それでも保存\"\n },\n \"configJsonError\": \"Config JSON の形式が正しくありません。構文を確認してください\",\n \"authJsonRequired\": \"auth.json は JSON オブジェクトで入力してください\",\n \"authJsonError\": \"auth.json の形式が正しくありません。JSON を確認してください\",\n \"fillAuthJson\": \"auth.json の設定を入力してください\",\n \"fillApiKey\": \"OPENAI_API_KEY を入力してください\",\n \"visitWebsite\": \"{{url}} を開く\",\n \"anthropicModel\": \"メインモデル\",\n \"anthropicSmallFastModel\": \"高速モデル\",\n \"apiFormat\": \"API フォーマット\",\n \"apiFormatHint\": \"プロバイダー API の入力フォーマットを選択\",\n \"customUserAgent\": \"カスタム User-Agent\",\n \"customUserAgentHint\": \"ローカルルーティング/プロキシ引き継ぎが有効な場合にのみ適用され、プロバイダー API へ転送するリクエストの User-Agent を置き換えます。\",\n \"customUserAgentInvalid\": \"User-Agent に制御文字(改行など)を含めることはできません。含まれている場合は無視されます。\",\n \"customUserAgentPresets\": \"プリセット\",\n \"localProxyRequestOverrides\": \"ローカルプロキシのリクエスト上書き\",\n \"localProxyRequestOverridesHint\": \"ローカルルーティング/プロキシ引き継ぎ後にのみ有効で、変換後の上流リクエストへ適用されます。\",\n \"localProxyRequestOverridesInvalid\": \"ローカルプロキシのリクエスト上書き形式エラー: {{error}}\",\n \"localProxyHeaderOverrides\": \"Header 上書き\",\n \"localProxyHeaderOverridesInvalidDetail\": \"Header 上書きの形式エラー: {{error}}\",\n \"localProxyBodyOverrides\": \"Body 上書き\",\n \"localProxyBodyOverridesInvalidDetail\": \"Body 上書きの形式エラー: {{error}}\",\n \"fullUrlLabel\": \"フル URL\",\n \"fullUrlEnabled\": \"フル URL モード\",\n \"fullUrlDisabled\": \"フル URL として設定\",\n \"fullUrlHint\": \"💡 完全なリクエスト URL を入力してください。このモードはルーティングを有効にして使用する必要があり、ルーティングはこの URL をそのまま使用し、パスを追加しません\",\n \"fullUrlHintGeminiNative\": \"💡 Gemini Native のフル URL モードでは 2 種類の入力を扱えます。1. 公式/構造化された Gemini URL は、要求されたモデルやストリーミング方式に合わせて正規化されます。2. カスタム relay の opaque な完全 URL は、主にそのまま使用され、必要なクエリだけ追加されます\",\n \"apiFormatAnthropic\": \"Anthropic Messages(ネイティブ)\",\n \"apiFormatOpenAIChat\": \"OpenAI Chat Completions(ルーティングが必要)\",\n \"apiFormatOpenAIResponses\": \"OpenAI Responses API(ルーティングが必要)\",\n \"apiFormatGeminiNative\": \"Gemini Native generateContent(ルーティングが必要)\",\n \"codexApiFormatResponses\": \"OpenAI Responses API(ネイティブ)\",\n \"codexApiFormatOpenAIChat\": \"OpenAI Chat Completions(ルーティングが必要)\",\n \"codexApiFormatHint\": \"このプロバイダーが実際に対応している Codex API フォーマットを選択します。新しい Codex との互換性のため設定は Responses のままにし、Chat Completions はローカルルーティングで変換します。\",\n \"authField\": \"認証フィールド\",\n \"authFieldAuthToken\": \"ANTHROPIC_AUTH_TOKEN(デフォルト)\",\n \"authFieldApiKey\": \"ANTHROPIC_API_KEY\",\n \"authFieldHint\": \"設定に書き込む認証環境変数名を選択\",\n \"apiHintResponses\": \"💡 OpenAI Responses API 互換サービスのエンドポイントを入力してください。末尾にスラッシュを付けないでください\",\n \"anthropicDefaultHaikuModel\": \"既定 Haiku モデル\",\n \"anthropicDefaultSonnetModel\": \"既定 Sonnet モデル\",\n \"anthropicDefaultOpusModel\": \"既定 Opus モデル\",\n \"modelPlaceholder\": \"\",\n \"smallModelPlaceholder\": \"\",\n \"haikuModelPlaceholder\": \"\",\n \"modelHelper\": \"任意: 既定で使いたい Claude モデルを指定。空欄ならシステム既定を使用します。\",\n \"modelMappingLabel\": \"モデルマッピング\",\n \"modelMappingHint\": \"表示名は /model メニューだけに影響します。1M は Claude Code へのコンテキスト能力宣言です。\",\n \"modelRoleLabel\": \"モデル役割\",\n \"modelRoleSonnet\": \"Sonnet\",\n \"modelRoleOpus\": \"Opus\",\n \"modelRoleFable\": \"Fable\",\n \"modelRoleHaiku\": \"Haiku\",\n \"modelRoleSubagent\": \"Subagent\",\n \"modelDisplayNameLabel\": \"表示名\",\n \"modelDisplayNamePlaceholder\": \"例: DeepSeek V4 Pro\",\n \"modelNoDisplayName\": \"/model には表示されません\",\n \"requestModelLabel\": \"リクエストモデル\",\n \"modelOneMHeader\": \"1M 対応を宣言\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"既定フォールバックモデル\",\n \"fallbackModelHint\": \"Sonnet・Opus・Fable・Haiku のいずれにも明確に対応しないリクエストのフォールバックです。サードパーティ/中継エンドポイントでは設定を推奨します。設定しないと、これらのリクエスト(Haiku のバックグラウンドサブタスクを含む)が元の Claude モデル名のまま上流へ転送され、上流に該当モデルが無い場合は失敗することがあります。公式エンドポイントでは空欄で構いません。\",\n \"quickSetModels\": \"一括設定\",\n \"quickSetSuccess\": \"モデル名をすべての役割に適用しました\",\n \"advancedOptionsToggle\": \"高級オプション\",\n \"advancedOptionsHint\": \"API フォーマット、認証フィールド、モデルマッピングの設定を含みます。通常はデフォルトのままで問題ありません。\",\n \"categoryOfficial\": \"公式\",\n \"categoryCnOfficial\": \"オープンソース公式\",\n \"categoryAggregation\": \"アグリゲーター\",\n \"categoryThirdParty\": \"サードパーティ\",\n \"fetchModels\": \"モデル一覧を取得\",\n \"fetchingModels\": \"取得中...\",\n \"fetchModelsSuccess\": \"{{count}}件のモデルを取得\",\n \"fetchModelsFailed\": \"モデル一覧の取得に失敗しました\",\n \"fetchModelsEmpty\": \"モデルが見つかりません\",\n \"fetchModelsNeedApiKey\": \"先に API Key を入力してください\",\n \"fetchModelsNeedEndpoint\": \"先に API エンドポイントを入力してください\",\n \"fetchModelsNeedConfig\": \"先に API エンドポイントと API Key を入力してください\",\n \"fetchModelsAuthFailed\": \"API Key が無効か、権限がありません\",\n \"fetchModelsNotSupported\": \"このプロバイダーはモデル一覧の取得に対応していません\",\n \"fetchModelsEndpointNotFound\": \"利用可能なモデル一覧エンドポイントが見つかりません。Base URL を確認するか、プロバイダーが該当 API を公開しているかご確認ください\",\n \"fetchModelsTimeout\": \"リクエストがタイムアウトしました。ネットワーク接続を確認してください\"\n },\n \"copilot\": {\n \"authSection\": \"GitHub Copilot 認証\",\n \"authStatus\": \"認証状態\",\n \"authenticated\": \"認証済み: {{username}}\",\n \"notAuthenticated\": \"未認証\",\n \"loginWithGitHub\": \"GitHub でログイン\",\n \"loginRequired\": \"先に GitHub Copilot にログインしてください\",\n \"waitingForAuth\": \"認証を待っています...\",\n \"enterCode\": \"ブラウザで以下のコードを入力してください:\",\n \"logout\": \"ログアウト\",\n \"authSuccess\": \"GitHub Copilot 認証に成功しました\",\n \"authFailed\": \"認証に失敗しました: {{error}}\",\n \"authTimeout\": \"認証がタイムアウトしました。もう一度お試しください\",\n \"tokenExpired\": \"トークンの有効期限が切れました。再認証してください\",\n \"accountCount\": \"{{count}} アカウント\",\n \"selectAccount\": \"アカウントを選択\",\n \"selectAccountPlaceholder\": \"GitHub アカウントを選択\",\n \"useDefaultAccount\": \"デフォルトアカウントを使用\",\n \"loggedInAccounts\": \"ログイン済みアカウント\",\n \"defaultAccount\": \"デフォルト\",\n \"selected\": \"選択中\",\n \"removeAccount\": \"アカウントを削除\",\n \"setAsDefault\": \"デフォルトに設定\",\n \"addAnotherAccount\": \"別のアカウントを追加\",\n \"logoutAll\": \"すべてのアカウントをログアウト\",\n \"retry\": \"再試行\",\n \"copyCode\": \"コードをコピー\",\n \"migrationFailed\": \"旧認証データの移行に失敗しました: {{error}}\",\n \"loadModelsFailed\": \"Copilot モデル一覧の読み込みに失敗しました\",\n \"deploymentType\": \"GitHub デプロイメントタイプ\",\n \"deploymentGitHubCom\": \"GitHub.com\",\n \"deploymentEnterprise\": \"GitHub Enterprise Server\",\n \"enterpriseDomainPlaceholder\": \"例: company.ghe.com\"\n },\n \"codexOauth\": {\n \"authStatus\": \"認証状態\",\n \"notAuthenticated\": \"未認証\",\n \"loginWithChatGPT\": \"ChatGPT でログイン\",\n \"loginRequired\": \"先に ChatGPT にログインしてください\",\n \"waitingForAuth\": \"認証を待機中...\",\n \"enterCode\": \"ブラウザで以下のコードを入力してください:\",\n \"accountCount\": \"{{count}} アカウント\",\n \"selectAccount\": \"アカウントを選択\",\n \"selectAccountPlaceholder\": \"ChatGPT アカウントを選択\",\n \"useDefaultAccount\": \"デフォルトアカウントを使用\",\n \"loggedInAccounts\": \"ログイン済みアカウント\",\n \"defaultAccount\": \"デフォルト\",\n \"selected\": \"選択中\",\n \"removeAccount\": \"アカウントを削除\",\n \"setAsDefault\": \"デフォルトに設定\",\n \"addAnotherAccount\": \"別のアカウントを追加\",\n \"logoutAll\": \"すべてのアカウントをログアウト\",\n \"retry\": \"再試行\",\n \"copyCode\": \"コードをコピー\",\n \"fastMode\": \"FAST モード\",\n \"fastModeDescription\": \"低遅延のため service_tier=\\\"priority\\\" を送信します。既定ではオフ——オンにすると ChatGPT のクォータがより速く消費されます。\"\n },\n \"endpointTest\": {\n \"title\": \"API エンドポイント管理\",\n \"endpoints\": \"エンドポイント\",\n \"autoSelect\": \"自動選択\",\n \"testSpeed\": \"テスト\",\n \"testing\": \"テスト中\",\n \"addEndpointPlaceholder\": \"https://api.example.com\",\n \"done\": \"完了\",\n \"noEndpoints\": \"エンドポイントがありません\",\n \"failed\": \"失敗\",\n \"enterValidUrl\": \"有効な URL を入力してください\",\n \"invalidUrlFormat\": \"URL 形式が正しくありません\",\n \"onlyHttps\": \"HTTP/HTTPS のみサポートします\",\n \"urlExists\": \"この URL はすでに存在します\",\n \"saveFailed\": \"保存に失敗しました。もう一度お試しください\",\n \"loadEndpointsFailed\": \"カスタムエンドポイントの読み込みに失敗:\",\n \"addEndpointFailed\": \"カスタムエンドポイントの追加に失敗:\",\n \"removeEndpointFailed\": \"カスタムエンドポイントの削除に失敗:\",\n \"removeFailed\": \"削除に失敗しました: {{error}}\",\n \"updateLastUsedFailed\": \"エンドポイントの最終使用時間の更新に失敗しました\",\n \"pleaseAddEndpoint\": \"まずエンドポイントを追加してください\",\n \"testUnavailable\": \"速度テストを実行できません\",\n \"noResult\": \"結果がありません\",\n \"testFailed\": \"速度テストに失敗しました: {{error}}\",\n \"empty\": \"エンドポイントがありません\"\n },\n \"providerAdvanced\": {\n \"pricingConfig\": \"課金設定\",\n \"useCustomPricing\": \"個別設定を使用\",\n \"pricingConfigDesc\": \"このプロバイダーに個別の課金パラメータを設定します。無効の場合はグローバル設定を使用します。\",\n \"costMultiplier\": \"コスト倍率\",\n \"costMultiplierPlaceholder\": \"空白の場合はグローバル設定を使用(1)\",\n \"costMultiplierHint\": \"実際のコスト = 基本コスト × 倍率、1.5 などの小数をサポート\",\n \"pricingModelSourceLabel\": \"課金モード\",\n \"pricingModelSourceInherit\": \"グローバル設定を継承\",\n \"pricingModelSourceRequest\": \"リクエストモデル\",\n \"pricingModelSourceResponse\": \"レスポンスモデル\",\n \"pricingModelSourceHint\": \"リクエストモデルまたはレスポンスモデルで価格を照合するかを選択\"\n },\n \"grokConfig\": {\n \"authJson\": \"Grok API Key (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"xai-your-api-key\\\"\\n}\",\n \"authJsonHint\": \"プロバイダー共通 API Key。各 [model.*] に api_key または env_key も設定できます\",\n \"profileToml\": \"Grok Profile (TOML)\",\n \"profileTomlHint\": \"endpoints、models、subagents、model.* を管理し、その他の Grok グローバル設定は保持します\",\n \"defaultModelPlaceholder\": \"例: grok-4.5\",\n \"defaultModelHint\": \"Grok Build がデフォルトで使用するモデルです。いつでも変更でき、空欄の場合は現在の Profile のデフォルトを保持します。\",\n \"advancedSectionHint\": \"Grok ネイティブ API Backend、User-Agent、ローカルプロキシ上書きを含みます。\",\n \"editGlobalConfig\": \"Grok グローバル設定を編集\",\n \"addToGlobalConfig\": \"グローバル設定に追加\",\n \"addedToGlobalConfig\": \"現在の Grok Profile をグローバル設定に追加しました\",\n \"globalConfigTitle\": \"Grok Build グローバル config.toml\",\n \"globalConfigHint\": \"ローカルの実設定を編集します。プロバイダー切替では endpoints、models、subagents、model.* のみ置換し、その他の設定は保持され、保存時に自動バックアップされます。\",\n \"globalConfigSaved\": \"Grok グローバル設定を保存しました\",\n \"applyPrivacy\": \"プライバシー保護を適用\",\n \"privacyApplied\": \"プライバシー保護設定を適用しました\"\n },\n \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex の auth.json 設定内容\",\n \"authJsonStorageHint\": \"プロキシ引き継ぎ中は保存済みの auth を表示しており、実際の auth.json ではありません。\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex の config.toml 設定内容\",\n \"configTomlStorageHint\": \"プロキシ引き継ぎ中は保存済みの config を表示しており、実際の config.toml ではありません。\",\n \"proxyTakeoverStorageNotice\": \"💡 このアプリはプロキシ引き継ぎ中です。ここで編集しているのは保存済みのプロバイダー設定で、実際の live 設定はすでにプロキシが一時的に引き継いでいます。\",\n \"writeCommonConfig\": \"共通設定を適用\",\n \"editCommonConfig\": \"共通設定を編集\",\n \"editCommonConfigTitle\": \"Codex 共通設定スニペットを編集\",\n \"commonConfigHint\": \"「共通設定を適用」がオンの場合、config.toml の末尾に追記されます\",\n \"enableGoalMode\": \"Goal mode を有効化\",\n \"enableRemoteCompaction\": \"リモート圧縮を有効化\",\n \"remoteCompactionHint\": \"有効にすると、現在の model_providers エントリの name を OpenAI に設定し、Codex がリモート圧縮を試行できるようにします。\",\n \"apiUrlLabel\": \"API リクエスト URL\",\n \"extractFromCurrent\": \"編集内容から抽出\",\n \"extractNoCommonConfig\": \"編集内容から抽出できる共通設定がありません\",\n \"extractFailed\": \"抽出に失敗しました: {{error}}\",\n \"saveFailed\": \"保存に失敗しました: {{error}}\",\n \"modelNameHint\": \"使用するモデルを指定します。config.toml に自動更新されます\",\n \"modelName\": \"モデル名\",\n \"upstreamFormatLabel\": \"上流フォーマット\",\n \"upstreamFormatHint\": \"プロバイダーがネイティブ Responses API なら Responses を選択(直結、フォーマット変換なし)。Chat Completions プロトコルなら Chat を選択。ネイティブ Anthropic Messages プロトコルのみ提供する場合は Anthropic Messages を選択。Chat と Anthropic Messages はどちらも Responses への変換にルーティング引き継ぎの有効化が必要です。\",\n \"upstreamFormatChat\": \"Chat Completions(ルーティング必須)\",\n \"upstreamFormatResponses\": \"Responses(ネイティブ)\",\n \"upstreamFormatAnthropic\": \"Anthropic Messages(ルーティング必須)\",\n \"anthropicAuthFieldLabel\": \"認証フィールド\",\n \"anthropicAuthFieldAuthToken\": \"ANTHROPIC_AUTH_TOKEN(Authorization)\",\n \"anthropicAuthFieldApiKey\": \"ANTHROPIC_API_KEY(x-api-key)\",\n \"anthropicAuthFieldHint\": \"ゲートウェイに API キーを渡すヘッダーを選択します。ANTHROPIC_AUTH_TOKEN は Authorization: Bearer、ANTHROPIC_API_KEY は x-api-key を送信します。送信されるのはどちらか一方のみです。\",\n \"impersonateClaudeCodeLabel\": \"Claude Code クライアントを模倣\",\n \"impersonateClaudeCodeHint\": \"ゲートウェイ(またはその上流)が Claude Code のみに制限している場合に有効化します。User-Agent・anthropic-beta・x-app ヘッダーを偽装し、システムプロンプトの先頭行に Claude Code のアイデンティティを注入します。\",\n \"maxOutputTokensLabel\": \"最大出力トークン\",\n \"maxOutputTokensPlaceholder\": \"空欄の場合はデフォルトの 8192 を使用\",\n \"maxOutputTokensHint\": \"Codex は model_max_output_tokens をリクエストボディに含めないため、デフォルト上限 8192 では長い回答や深い思考時に切り詰められることがあります(stop_reason=max_tokens)。この値は Anthropic 経路で max_tokens を上書きします。モデル/ゲートウェイの実際の出力上限を超えると 400 になる場合があります。空欄でデフォルトの 8192 を使用します。\",\n \"promptCacheRoutingLabel\": \"プロンプトキャッシュルーティング\",\n \"promptCacheRoutingAuto\": \"自動(推奨)\",\n \"promptCacheRoutingEnabled\": \"有効\",\n \"promptCacheRoutingDisabled\": \"無効\",\n \"promptCacheRoutingHint\": \"自動モードでは、互換性を確認済みの上流にのみ prompt_cache_key を送信します。他の互換ゲートウェイでは有効にし、未知のフィールドを 400 で拒否する厳格なゲートウェイでは無効にしてください。クライアントが提供した安定したセッション ID のみを使用します。\",\n \"upstreamModelName\": \"上流モデル名\",\n \"upstreamModelNameHint\": \"Chat 形式ではここに実際の上流モデルを入力します。ルーティングで Codex の Responses リクエストを Chat Completions に変換し、このモデルを維持します。\",\n \"modelNamePlaceholder\": \"例: gpt-5-codex\",\n \"contextWindow1M\": \"1M コンテキストウィンドウ\",\n \"contextWindow\": \"コンテキストウィンドウ\",\n \"contextWindowPlaceholder\": \"例: 128000\",\n \"autoCompactLimit\": \"自動圧縮しきい値\",\n \"autoCompactLimitPlaceholder\": \"例: 90000\",\n \"autoCompactLimitHint\": \"コンテキストトークン数がこのしきい値に達すると履歴を自動圧縮\",\n \"modelMetadataAdvanced\": \"モデルメタデータ詳細オプション\",\n \"modelMetadataAdvancedHint\": \"未知のモデル向けに Codex メタデータを上書きします。空にすると対応する config.toml 設定を削除します。\",\n \"defaultModelLabel\": \"デフォルトモデル\",\n \"defaultModelPlaceholder\": \"例: gpt-5.6\",\n \"defaultModelHint\": \"Codex がデフォルトでリクエストするモデルです。プリセットの更新を待たずにいつでも変更できます。空欄のままモデルマッピングを設定している場合は、マッピングの先頭行が使われます。\",\n \"defaultModelNotInCatalog\": \"このモデルはモデルマッピングにないため、Codex の /model メニューには表示されません(直接リクエストは有効です)。\",\n \"addToModelMapping\": \"マッピングに追加\",\n \"modelMappingTitle\": \"モデルマッピング\",\n \"modelMappingHint\": \"Codex model_catalog_json を生成し、/model コマンドでこれらの第三者モデル名を表示できるようにします。リストの内容はそのまま保存されます。変更後、モデルリストを更新するには Codex の再起動が必要です。\",\n \"addCatalogModel\": \"モデルを追加\",\n \"catalogDisplayName\": \"表示名\",\n \"catalogDisplayNamePlaceholder\": \"例: DeepSeek V4 Flash\",\n \"catalogModelId\": \"モデル ID\",\n \"catalogModelPlaceholder\": \"例: deepseek-v4-flash\",\n \"catalogColumnDisplay\": \"メニュー表示名\",\n \"catalogColumnModel\": \"実際のリクエストモデル\",\n \"catalogColumnContext\": \"コンテキストウィンドウ\",\n \"reasoningModeToggle\": \"思考モードに対応\",\n \"reasoningModeHint\": \"上流の Chat Completions API が思考(thinking)のオン/オフ切り替えに対応している場合に有効化します。Kimi、GLM、Qwen などが通常このタイプに該当します。\",\n \"reasoningEffortToggle\": \"思考レベルに対応\",\n \"reasoningEffortHint\": \"上流が low/high/max などの思考の深さの制御に対応している場合に有効化します。有効にすると思考モードも自動的にオンになり、Codex の reasoning.effort を上流の Chat パラメータに変換します。\",\n \"reasoningGroupTitle\": \"思考能力\",\n \"reasoningSectionHint\": \"プリセットの供給元は自動的に設定され、カスタム供給元は名前/URL から自動推論されます。自動識別が正しくない場合のみ手動で上書きしてください。\",\n \"advancedSectionHint\": \"上流フォーマット、モデルマッピング、思考能力、カスタム User-Agent の設定を含みます。Chat Completions プロトコルを使用するプロバイダーはルーティング引き継ぎの有効化が必要です。\"\n },\n \"geminiConfig\": {\n \"envFile\": \"環境変数 (.env)\",\n \"envFileHint\": \".env 形式で Gemini の環境変数を設定\",\n \"configJson\": \"設定ファイル (config.json)\",\n \"configJsonHint\": \"Gemini 拡張パラメーターを JSON 形式で設定(任意)\",\n \"writeCommonConfig\": \"共通設定を適用\",\n \"editCommonConfig\": \"共通設定を編集\",\n \"editCommonConfigTitle\": \"Gemini 共通設定スニペットを編集\",\n \"commonConfigHint\": \"「共通設定を適用」がオンのとき Gemini の .env に書き込みます(GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY は使用できません)\",\n \"extractFromCurrent\": \"編集内容から抽出\",\n \"extractNoCommonConfig\": \"編集内容から抽出できる共通設定がありません\",\n \"extractFailed\": \"抽出に失敗しました: {{error}}\",\n \"saveFailed\": \"保存に失敗しました: {{error}}\",\n \"extractedConfigInvalid\": \"抽出した設定のフォーマットが不正です\",\n \"invalidJsonFormat\": \"共通設定スニペットの形式が不正です(有効な JSON でなければなりません)\",\n \"commonConfigInvalidKeys\": \"共通設定スニペットに GOOGLE_GEMINI_BASE_URL または GEMINI_API_KEY を含めることはできません(検出: {{keys}})\",\n \"commonConfigInvalidValues\": \"共通設定スニペットの値は文字列である必要があります\",\n \"noCommonConfigToApply\": \"共通設定スニペットが空、または適用できる項目がありません\",\n \"configMergeFailed\": \"設定のマージに失敗しました: {{error}}\",\n \"configReplaceFailed\": \"設定の置換に失敗しました: {{error}}\"\n },\n \"opencode\": {\n \"npmPackage\": \"API フォーマット\",\n \"selectPackage\": \"API フォーマットを選択\",\n \"npmPackageHint\": \"AI サービスの API フォーマットを選択\",\n \"baseUrl\": \"Base URL\",\n \"baseUrlHint\": \"カスタム API エンドポイント URL\",\n \"models\": \"モデル設定\",\n \"modelsHint\": \"利用可能なモデルとその表示名を設定\",\n \"addModel\": \"モデルを追加\",\n \"modelId\": \"モデル ID\",\n \"modelName\": \"表示名\",\n \"noModels\": \"モデルが設定されていません\",\n \"modelsRequired\": \"モデルを少なくとも1つ追加してください\",\n \"toggleModelDetails\": \"モデル詳細を開閉\",\n \"providerKey\": \"プロバイダーキー\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定ファイルの一意の識別子です。小文字、数字、ハイフンのみ使用できます。\",\n \"providerKeyLockedHint\": \"このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。\",\n \"providerKeyRequired\": \"プロバイダーキーを入力してください\",\n \"providerKeyDuplicate\": \"このキーは既に使用されています\",\n \"providerKeyInvalid\": \"無効な形式です。小文字、数字、ハイフンのみ使用できます。\",\n \"headers\": \"ヘッダー\",\n \"headersHint\": \"HTTP-Referer や X-Title など、プロバイダーリクエストに付与する任意の HTTP ヘッダーです。\",\n \"addHeader\": \"ヘッダーを追加\",\n \"headerName\": \"ヘッダー\",\n \"headerValue\": \"値\",\n \"headerNamePlaceholder\": \"X-Title\",\n \"headerValuePlaceholder\": \"CC Switch\",\n \"removeHeader\": \"ヘッダーを削除\",\n \"noHeaders\": \"カスタムヘッダーはありません\",\n \"extraOptions\": \"追加オプション\",\n \"extraOptionsHint\": \"timeout、setCacheKey などの SDK オプションを設定。値は自動的に適切な型(数値、真偽値など)に変換されます。\",\n \"addExtraOption\": \"追加\",\n \"extraOptionKey\": \"キー名\",\n \"extraOptionValue\": \"値\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"追加オプションはありません\",\n \"noModelOptions\": \"モデルオプション、+ をクリックして追加\",\n \"modelLimits\": \"トークン制限\",\n \"limitContext\": \"コンテキスト\",\n \"limitOutput\": \"出力\",\n \"modelExtraFields\": \"モデルプロパティ\",\n \"noModelExtraFields\": \"モデルプロパティ(variants、cost など)、+ をクリックして追加\",\n \"modelExtraFieldKeyPlaceholder\": \"variants\",\n \"sdkOptions\": \"SDK オプション\",\n \"modelOptionKeyPlaceholder\": \"provider\",\n \"modelOptionValuePlaceholder\": \"{\\\"order\\\": [\\\"baseten\\\"]}\"\n },\n \"providerPreset\": {\n \"label\": \"プロバイダータイプ\",\n \"custom\": \"カスタム設定\",\n \"other\": \"その他\",\n \"hint\": \"プリセットを選んだ後でも、下のフィールドで調整できます。\",\n \"searchTooltip\": \"プリセットを検索\",\n \"searchAriaLabel\": \"プロバイダープリセットを検索\",\n \"searchPlaceholder\": \"名前、サイト、カテゴリで検索...\",\n \"sortAriaLabel\": \"プリセットの並び順を切り替え\",\n \"sortNameAscTooltip\": \"名前順 A-Z で並び替え\",\n \"sortOriginalTooltip\": \"元の順序に戻す\",\n \"noSearchResults\": \"一致するプロバイダープリセットがありません。\"\n },\n \"usage\": {\n \"title\": \"利用統計\",\n \"subtitle\": \"AI モデルの利用状況とコスト統計を表示\",\n \"today\": \"24時間\",\n \"last7days\": \"7日間\",\n \"last30days\": \"30日間\",\n \"presetToday\": \"当日\",\n \"preset1d\": \"1d\",\n \"preset7d\": \"7d\",\n \"preset14d\": \"14d\",\n \"preset30d\": \"30d\",\n \"totalRequests\": \"総リクエスト数\",\n \"totalCost\": \"総コスト\",\n \"cost\": \"コスト\",\n \"perMillion\": \"(100万あたり)\",\n \"trends\": \"利用トレンド\",\n \"rangeToday\": \"直近24時間 (時間別)\",\n \"rangeLast7Days\": \"過去7日間\",\n \"rangeLast30Days\": \"過去30日間\",\n \"totalTokens\": \"新規トークン (Input+Output)\",\n \"cacheTokens\": \"キャッシュトークン\",\n \"realTotal\": \"実消費トークン\",\n \"realTotalHint\": \"Input + Output + キャッシュ書込 + キャッシュ命中\",\n \"freshInput\": \"新規入力\",\n \"cacheHitRate\": \"キャッシュヒット率\",\n \"requestLogs\": \"リクエストログ\",\n \"providerStats\": \"プロバイダー統計\",\n \"modelStats\": \"モデル統計\",\n \"time\": \"時間\",\n \"provider\": \"プロバイダー\",\n \"billingModel\": \"課金モデル\",\n \"inputTokens\": \"入力\",\n \"outputTokens\": \"出力\",\n \"cacheReadTokens\": \"キャッシュヒット\",\n \"cacheCreationTokens\": \"キャッシュ作成\",\n \"timingInfo\": \"応答時間/TTFT\",\n \"status\": \"ステータス\",\n \"multiplier\": \"倍率\",\n \"requestModel\": \"リクエストモデル\",\n \"responseModel\": \"レスポンスモデル\",\n \"noData\": \"データなし\",\n \"unknownProvider\": \"不明なプロバイダー\",\n \"stream\": \"ストリーム\",\n \"nonStream\": \"非ストリーム\",\n \"source\": \"ソース\",\n \"requestsLabel\": \"リクエスト\",\n \"costLabel\": \"合計コスト\",\n \"appFilter\": {\n \"all\": \"すべて\",\n \"claude\": \"Claude Code\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\"\n },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"データソース\",\n \"dataSource\": {\n \"proxy\": \"ルーティング\",\n \"session_log\": \"セッションログ\",\n \"codex_db\": \"Codex DB\",\n \"codex_session\": \"Codex セッション\",\n \"gemini_session\": \"Gemini セッション\",\n \"opencode_session\": \"OpenCode セッション\"\n },\n \"sessionSync\": {\n \"trigger\": \"セッションログを同期\",\n \"import\": \"セッションをインポート\",\n \"resync\": \"同期\",\n \"imported\": \"セッションログから {{count}} 件のレコードをインポートしました\",\n \"upToDate\": \"セッションログは最新です\",\n \"failed\": \"セッション同期に失敗しました\"\n },\n \"totalRecords\": \"全 {{total}} 件\",\n \"goToPage\": \"移動\",\n \"pageInputPlaceholder\": \"ページ\",\n \"modelPricing\": \"モデル料金\",\n \"loadPricingError\": \"料金データの読み込みに失敗しました\",\n \"modelPricingDesc\": \"各モデルのトークンコストを設定\",\n \"noPricingData\": \"料金データがありません。「追加」をクリックしてモデル料金を設定してください。\",\n \"model\": \"モデル\",\n \"pricingModel\": \"課金モデル\",\n \"displayName\": \"表示名\",\n \"inputCost\": \"入力コスト\",\n \"outputCost\": \"出力コスト\",\n \"cacheReadCost\": \"キャッシュヒット\",\n \"cacheWriteCost\": \"キャッシュ作成\",\n \"deleteConfirmTitle\": \"削除の確認\",\n \"deleteConfirmDesc\": \"このモデル料金を削除しますか?この操作は元に戻せません。\",\n \"queryFailed\": \"照会に失敗しました\",\n \"refreshUsage\": \"利用状況を更新\",\n \"planUsage\": \"プラン利用状況\",\n \"invalid\": \"期限切れ\",\n \"total\": \"合計:\",\n \"used\": \"使用:\",\n \"remaining\": \"残り:\",\n \"justNow\": \"たった今\",\n \"minutesAgo\": \"{{count}} 分前\",\n \"hoursAgo\": \"{{count}} 時間前\",\n \"daysAgo\": \"{{count}} 日前\",\n \"multiplePlans\": \"{{count}} プラン\",\n \"expand\": \"展開\",\n \"collapse\": \"折りたたむ\",\n \"modelIdPlaceholder\": \"例: claude-3-5-sonnet-20241022\",\n \"displayNamePlaceholder\": \"例: Claude 3.5 Sonnet\",\n \"appType\": \"アプリ種別\",\n \"statusCode\": \"ステータスコード\",\n \"allSources\": \"すべてのソース\",\n \"allModels\": \"すべてのモデル\",\n \"filterBySource\": \"ソースで絞り込む\",\n \"filterByModel\": \"モデルで絞り込む\",\n \"refreshInterval\": \"自動更新間隔\",\n \"refreshOff\": \"オフ\",\n \"timeRange\": \"期間\",\n \"customRange\": \"カレンダーフィルター\",\n \"customRangeHint\": \"日付と時刻の両方に対応\",\n \"startTime\": \"開始時刻\",\n \"endTime\": \"終了時刻\",\n \"liveEndTime\": \"終了時刻を現在時刻に追従\",\n \"liveEndTimeNow\": \"現在\",\n \"input\": \"Input\",\n \"output\": \"Output\",\n \"cacheWrite\": \"作成\",\n \"cacheWriteNotReported\": \"OpenAI プロトコルはキャッシュ作成を区別せず、キャッシュヒットのみ報告します\",\n \"cacheWritePartial\": \"一部のプロトコル(OpenAIなど)はキャッシュ作成を報告しないため、値は不完全な可能性があります\",\n \"cacheRead\": \"ヒット\",\n \"baseCost\": \"基本\",\n \"costMultiplier\": \"コスト倍率\",\n \"withMultiplier\": \"倍率込み\",\n \"requestDetail\": \"リクエスト詳細\",\n \"requestNotFound\": \"リクエストが見つかりません\",\n \"basicInfo\": \"基本情報\",\n \"tokenUsage\": \"Token 使用量\",\n \"cacheCreationCost\": \"キャッシュ作成コスト\",\n \"costBreakdown\": \"コスト明細\",\n \"performance\": \"パフォーマンス\",\n \"latency\": \"レイテンシー\",\n \"errorMessage\": \"エラーメッセージ\",\n \"requests\": \"リクエスト数\",\n \"tokens\": \"トークン\",\n \"avgCost\": \"平均コスト\",\n \"avgLatency\": \"平均レイテンシ\",\n \"successRate\": \"成功率\",\n \"requestId\": \"リクエスト ID\",\n \"never\": \"なし\",\n \"modelId\": \"モデル ID\",\n \"modelIdRequired\": \"モデル ID は必須です\",\n \"inputCostPerMillion\": \"入力コスト(100万トークンあたり、USD)\",\n \"outputCostPerMillion\": \"出力コスト(100万トークンあたり、USD)\",\n \"invalidPrice\": \"価格は負でない数値である必要があります\",\n \"invalidTimeRange\": \"開始/終了時刻を完全に選択してください\",\n \"invalidTimeRangeOrder\": \"開始時刻は終了時刻より前である必要があります\",\n \"timeRangeTooLarge\": \"時間範囲が大きすぎます。範囲を縮小してください\",\n \"addPricing\": \"価格設定を追加\",\n \"editPricing\": \"価格設定を編集\",\n \"pricingAdded\": \"価格設定が追加されました\",\n \"pricingUpdated\": \"価格設定が更新されました\",\n \"importFromModelsDev\": \"models.dev からインポート\",\n \"modelsDevHint\": \"手動入力の代わりに、models.dev からモデル価格を選択できます\",\n \"modelsDevPickerTitle\": \"models.dev から価格をインポート\",\n \"modelsDevPickerDesc\": \"インポートするモデルを選択してください(価格単位:USD / 100万トークン)。1回につき1モデルです。\",\n \"modelsDevSearchPlaceholder\": \"モデルまたはプロバイダーを検索(全件検索)...\",\n \"modelsDevAllProviders\": \"すべてのプロバイダー\",\n \"modelsDevLoadError\": \"models.dev データの読み込みに失敗しました\",\n \"modelsDevRetry\": \"再試行\",\n \"modelsDevImportButton\": \"インポート\",\n \"modelsDevImporting\": \"インポート中...\",\n \"modelsDevImported\": \"{{name}} の価格をインポートしました\",\n \"modelsDevNoResults\": \"一致するモデルがありません\",\n \"modelsDevTruncated\": \"{{total}} 件中、先頭 {{shown}} 件のみ表示しています。検索条件を絞り込んでください\",\n \"modelsDevDefaultHint\": \"最新リリース順に {{shown}} 件を表示しています(全 {{total}} 件)。キーワード入力で全件検索できます\",\n \"cacheReadCostPerMillion\": \"キャッシュ読み取りコスト(100万トークンあたり、USD)\",\n \"cacheCreationCostPerMillion\": \"キャッシュ書き込みコスト(100万トークンあたり、USD)\"\n },\n \"usageScript\": {\n \"title\": \"利用状況を設定\",\n \"enableUsageQuery\": \"利用状況照会を有効にする\",\n \"presetTemplate\": \"プリセットテンプレート\",\n \"requestUrl\": \"リクエスト URL\",\n \"requestUrlPlaceholder\": \"例: https://api.example.com\",\n \"method\": \"HTTP メソッド\",\n \"templateCustom\": \"カスタム\",\n \"templateGeneral\": \"General\",\n \"templateNewAPI\": \"NewAPI\",\n \"templateCopilot\": \"GitHub Copilot\",\n \"templateTokenPlan\": \"Token Plan\",\n \"templateBalance\": \"公式\",\n \"templateOfficialSubscription\": \"公式サブスクリプション\",\n \"copilotAutoAuth\": \"OAuth 認証を自動使用、手動設定不要\",\n \"tokenPlanHint\": \"プロバイダーのAPI KeyとBase URLを使用してToken Planクォータを自動クエリ\",\n \"balanceHint\": \"プロバイダーのAPI Keyを使用してアカウント残高を自動クエリ\",\n \"officialSubscriptionHint\": \"ローカル CLI の OAuth 認証情報を読み取り、公式 API でサブスクリプション枠を照会します。既定では無効で、有効化した後のみリクエストします。\",\n \"resetDate\": \"リセット日\",\n \"premiumRequests\": \"Premium リクエスト\",\n \"credentialsConfig\": \"認証情報\",\n \"credentialsHint\": \"空欄の場合はプロバイダー設定を使用\",\n \"optional\": \"オプション\",\n \"apiKeyPlaceholder\": \"空欄の場合はプロバイダーの API Key を使用\",\n \"baseUrlPlaceholder\": \"空欄の場合はプロバイダーの Base URL を使用\",\n \"baseUrl\": \"Base URL\",\n \"accessToken\": \"Access Token\",\n \"accessTokenPlaceholder\": \"「Security Settings」で生成\",\n \"userId\": \"ユーザー ID\",\n \"userIdPlaceholder\": \"例: 114514\",\n \"accessKeyId\": \"AccessKey ID\",\n \"secretAccessKey\": \"SecretAccessKey\",\n \"volcengineAkSkHint\": \"Volcengine の使用量照会にはアカウントレベルの AccessKey ID / Secret が必要です(推論 API キーとは別物)。Volcengine コンソール右上のアカウントメニュー →「API アクセスキー」で作成してください。\",\n \"volcengineKeyConsoleLink\": \"キー作成ページ:\",\n \"zhipuTeamHint\": \"智譜チームプランの使用量照会には、チーム API キー(このプロバイダーの認証情報を流用)+ 組織 ID + プロジェクト ID が必要です(リクエストヘッダー bigmodel-organization / bigmodel-project として送信)。組織 ID とプロジェクト ID はチーム管理コンソールの使用量ページの URL で確認できます。\",\n \"zhipuTeamConsoleLink\": \"チーム使用量ページ:\",\n \"organizationId\": \"組織 ID\",\n \"organizationIdPlaceholder\": \"例: org-xxxxxx\",\n \"projectId\": \"プロジェクト ID\",\n \"projectIdPlaceholder\": \"例: proj-xxxxxx\",\n \"defaultPlan\": \"デフォルトプラン\",\n \"queryFailedMessage\": \"照会に失敗しました\",\n \"queryScript\": \"照会スクリプト (JavaScript)\",\n \"timeoutSeconds\": \"タイムアウト(秒)\",\n \"headers\": \"ヘッダー\",\n \"body\": \"ボディ\",\n \"timeoutHint\": \"範囲: 2〜30 秒\",\n \"timeoutMustBeInteger\": \"タイムアウトは整数で入力してください(小数は切り捨て)\",\n \"timeoutCannotBeNegative\": \"タイムアウトは負の値にできません\",\n \"autoIntervalMinutes\": \"自動照会間隔(分、0 で無効)\",\n \"autoQueryInterval\": \"自動照会間隔(分)\",\n \"autoQueryIntervalHint\": \"0 で無効。推奨 5〜60 分\",\n \"intervalMustBeInteger\": \"間隔は整数で入力してください(小数は切り捨て)\",\n \"intervalCannotBeNegative\": \"間隔は負の値にできません\",\n \"intervalAdjusted\": \"間隔を {{value}} 分に調整しました\",\n \"scriptHelp\": \"スクリプトの書き方:\",\n \"configFormat\": \"設定の形式:\",\n \"commentOptional\": \"任意\",\n \"commentResponseIsJson\": \"response は API から返る JSON データです\",\n \"extractorFormat\": \"抽出関数の返却形式(すべて任意):\",\n \"tips\": \"💡 ヒント:\",\n \"testing\": \"テスト中...\",\n \"testScript\": \"スクリプトをテスト\",\n \"format\": \"整形\",\n \"saveConfig\": \"設定を保存\",\n \"scriptEmpty\": \"スクリプト設定は空にできません\",\n \"mustHaveReturn\": \"スクリプトには return 文が必要です\",\n \"testSuccess\": \"テスト成功!\",\n \"testFailed\": \"テストに失敗しました\",\n \"formatSuccess\": \"整形に成功しました\",\n \"formatFailed\": \"整形に失敗しました\",\n \"supportedVariables\": \"使用可能な変数\",\n \"variablesHint\": \"使用可能な変数: {{apiKey}}, {{baseUrl}} | extractor 関数には API 応答の JSON オブジェクトが渡されます\",\n \"scriptConfig\": \"リクエスト設定\",\n \"extractorCode\": \"抽出コード\",\n \"extractorHint\": \"戻り値のオブジェクトに残り枠の項目を含めてください\",\n \"fieldIsValid\": \"• isValid: Boolean。プランが有効かどうか\",\n \"fieldInvalidMessage\": \"• invalidMessage: String。無効時の理由(isValid が false のとき表示)\",\n \"fieldRemaining\": \"• remaining: Number。残り枠\",\n \"fieldUnit\": \"• unit: String。単位(例: \\\"USD\\\")\",\n \"fieldPlanName\": \"• planName: String。プラン名\",\n \"fieldTotal\": \"• total: Number。総枠\",\n \"fieldUsed\": \"• used: Number。使用量\",\n \"fieldExtra\": \"• extra: String。自由記述の追加テキスト\",\n \"tip1\": \"• 変数 {{apiKey}} と {{baseUrl}} は自動で置換されます\",\n \"tip2\": \"• 抽出関数はサンドボックスで実行され、ES2020+ の構文を使えます\",\n \"tip3\": \"• 全体を () で囲み、オブジェクトリテラル式にしてください\"\n },\n \"dbUpgrade\": {\n \"title\": \"データベースのバージョンが新しすぎます\",\n \"description\": \"このデータベースは新しいバージョンの CC Switch で作成されています。続行するにはアプリを更新してください。更新してもデータは削除されません。\",\n \"versionInfo\": \"データベース v{{db}} · アプリ対応 v{{supported}}\",\n \"dbPath\": \"データベースファイル\",\n \"checking\": \"利用可能な更新を確認中…\",\n \"updateAvailable\": \"新しいバージョン v{{version}} が利用可能です。更新すると続行できます。\",\n \"incompatibleTitle\": \"更新しても解決できません\",\n \"incompatibleDescription\": \"すでに最新バージョンですが、データベース(v{{db}})はこのアプリの対応バージョン(v{{supported}})より新しいままです。サードパーティ製クライアントまたはより新しいビルドで作成された可能性があり、公式アプリを更新しても互換性は得られません。\",\n \"preparing\": \"更新を準備中…\",\n \"downloading\": \"更新をダウンロード中…\",\n \"upgradeNow\": \"アプリを更新\",\n \"retry\": \"更新を再試行\",\n \"openReleases\": \"リリースページを開く\",\n \"openConfigDir\": \"設定フォルダを開く\",\n \"quit\": \"終了\"\n },\n \"errors\": {\n \"usage_query_failed\": \"利用状況の取得に失敗しました\",\n \"configLoadFailedTitle\": \"設定の読み込みに失敗しました\",\n \"configLoadFailedMessage\": \"設定ファイルを読み込めません:\\n{{path}}\\n\\nエラー詳細:\\n{{detail}}\\n\\nJSON が正しいか確認するか、同じディレクトリのバックアップファイル(config.json.bak など)から復元してください。\\n\\nアプリを終了して修正してください。\"\n },\n \"presetSelector\": {\n \"title\": \"設定タイプを選択\",\n \"custom\": \"カスタム\",\n \"customDescription\": \"手動で設定。完全な構成が必要\",\n \"officialDescription\": \"公式ログイン。API Key 不要\",\n \"presetDescription\": \"プリセットを使用。API Key だけ入力すれば OK\"\n },\n \"mcp\": {\n \"title\": \"MCP 管理\",\n \"import\": \"インポート\",\n \"importExisting\": \"既存をインポート\",\n \"addMcp\": \"MCPを追加\",\n \"claudeTitle\": \"Claude Code MCP 管理\",\n \"codexTitle\": \"Codex MCP 管理\",\n \"geminiTitle\": \"Gemini MCP 管理\",\n \"unifiedPanel\": {\n \"title\": \"MCP サーバー管理\",\n \"addServer\": \"サーバーを追加\",\n \"editServer\": \"サーバーを編集\",\n \"deleteServer\": \"サーバーを削除\",\n \"deleteConfirm\": \"サーバー「{{id}}」を削除しますか?この操作は元に戻せません。\",\n \"noServers\": \"まだサーバーがありません\",\n \"enabledApps\": \"有効なアプリ\",\n \"noImportFound\": \"インポートする MCP サーバーが見つかりませんでした。すべてのサーバーは CC Switch で管理されています。\",\n \"importSuccess\": \"{{count}} 個の MCP サーバーをインポートしました\",\n \"apps\": {\n \"claude\": \"Claude\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\",\n \"openclaw\": \"OpenClaw\",\n \"hermes\": \"Hermes\"\n }\n },\n \"userLevelPath\": \"ユーザーレベルの MCP パス\",\n \"serverList\": \"サーバー一覧\",\n \"loading\": \"読み込み中...\",\n \"empty\": \"MCP サーバーがありません\",\n \"emptyDescription\": \"右上のボタンから最初の MCP サーバーを追加してください\",\n \"add\": \"MCP を追加\",\n \"addServer\": \"MCP を追加\",\n \"editServer\": \"MCP を編集\",\n \"addClaudeServer\": \"Claude Code MCP を追加\",\n \"editClaudeServer\": \"Claude Code MCP を編集\",\n \"addCodexServer\": \"Codex MCP を追加\",\n \"editCodexServer\": \"Codex MCP を編集\",\n \"configPath\": \"設定パス\",\n \"serverCount\": \"MCP サーバー: {{count}} 件\",\n \"enabledCount\": \"{{count}} 件が有効\",\n \"template\": {\n \"fetch\": \"クイックテンプレート: mcp-fetch\"\n },\n \"form\": {\n \"title\": \"MCP ID(ユニーク)\",\n \"titlePlaceholder\": \"my-mcp-server\",\n \"name\": \"表示名\",\n \"namePlaceholder\": \"例: @modelcontextprotocol/server-time\",\n \"enabledApps\": \"適用するアプリ\",\n \"noAppsWarning\": \"少なくとも 1 つ選択してください\",\n \"description\": \"説明\",\n \"descriptionPlaceholder\": \"任意の説明\",\n \"tags\": \"タグ(カンマ区切り)\",\n \"tagsPlaceholder\": \"stdio, time, utility\",\n \"homepage\": \"ホームページ\",\n \"homepagePlaceholder\": \"https://example.com\",\n \"docs\": \"ドキュメント\",\n \"docsPlaceholder\": \"https://example.com/docs\",\n \"additionalInfo\": \"追加情報\",\n \"jsonConfig\": \"JSON 全設定\",\n \"jsonConfigOrPrefix\": \"JSON 全設定、または\",\n \"tomlConfigOrPrefix\": \"TOML 全設定、または\",\n \"jsonPlaceholder\": \"{\\n \\\"type\\\": \\\"stdio\\\",\\n \\\"command\\\": \\\"uvx\\\",\\n \\\"args\\\": [\\\"mcp-server-fetch\\\"]\\n}\",\n \"tomlConfig\": \"TOML 全設定\",\n \"tomlPlaceholder\": \"type = \\\"stdio\\\"\\ncommand = \\\"uvx\\\"\\nargs = [\\\"mcp-server-fetch\\\"]\",\n \"useWizard\": \"設定ウィザード\",\n \"syncOtherSide\": \"{{target}} にも反映\",\n \"syncOtherSideHint\": \"{{target}} に同じ設定を書き込みます。既存の同一 ID は上書きされます。\",\n \"willOverwriteWarning\": \"{{target}} の既存設定を上書きします\"\n },\n \"wizard\": {\n \"title\": \"MCP 設定ウィザード\",\n \"hint\": \"MCP サーバーを素早く設定し JSON を自動生成します\",\n \"type\": \"タイプ\",\n \"typeStdio\": \"stdio\",\n \"typeHttp\": \"http\",\n \"typeSse\": \"sse\",\n \"command\": \"コマンド\",\n \"commandPlaceholder\": \"npx または uvx\",\n \"args\": \"引数\",\n \"argsPlaceholder\": \"arg1\\narg2\",\n \"env\": \"環境変数\",\n \"envPlaceholder\": \"KEY1=value1\\nKEY2=value2\",\n \"url\": \"URL\",\n \"urlPlaceholder\": \"https://api.example.com/mcp\",\n \"urlRequired\": \"URL を入力してください\",\n \"headers\": \"ヘッダー(任意)\",\n \"headersPlaceholder\": \"Authorization: Bearer your_token_here\\nContent-Type: application/json\",\n \"preview\": \"設定プレビュー\",\n \"apply\": \"設定を反映\"\n },\n \"id\": \"識別子(ユニーク)\",\n \"type\": \"タイプ\",\n \"command\": \"コマンド\",\n \"validateCommand\": \"コマンドを検証\",\n \"args\": \"引数\",\n \"argsPlaceholder\": \"例: mcp-server-fetch --help\",\n \"env\": \"環境変数(1 行に 1 件、KEY=VALUE)\",\n \"envPlaceholder\": \"FOO=bar\\nHELLO=world\",\n \"reset\": \"リセット\",\n \"msg\": {\n \"saved\": \"保存しました\",\n \"deleted\": \"削除しました\",\n \"enabled\": \"有効化しました\",\n \"disabled\": \"無効化しました\",\n \"templateAdded\": \"テンプレートを追加しました\"\n },\n \"error\": {\n \"idRequired\": \"識別子を入力してください\",\n \"idExists\": \"この識別子は既に存在します。別のものを選んでください。\",\n \"jsonInvalid\": \"JSON 形式が無効です\",\n \"tomlInvalid\": \"TOML 形式が無効です\",\n \"commandRequired\": \"コマンドを入力してください\",\n \"singleServerObjectRequired\": \"単一の MCP サーバーオブジェクトを貼り付けてください(トップレベルの mcpServers は不要)\",\n \"saveFailed\": \"保存に失敗しました\",\n \"deleteFailed\": \"削除に失敗しました\"\n },\n \"validation\": {\n \"ok\": \"コマンドが見つかりました\",\n \"fail\": \"コマンドが見つかりません\"\n },\n \"confirm\": {\n \"deleteTitle\": \"MCP サーバーを削除\",\n \"deleteMessage\": \"MCP サーバー「{{id}}」を削除してもよろしいですか?この操作は元に戻せません。\"\n },\n \"presets\": {\n \"title\": \"MCP タイプを選択\",\n \"enable\": \"有効化\",\n \"enabled\": \"有効\",\n \"installed\": \"インストール済み\",\n \"docs\": \"ドキュメント\",\n \"requiresEnv\": \"環境変数が必要\",\n \"fetch\": {\n \"name\": \"mcp-server-fetch\",\n \"description\": \"汎用 HTTP リクエストツール。GET/POST などに対応し、API テストや Web データ取得に最適です\"\n },\n \"time\": {\n \"name\": \"@modelcontextprotocol/server-time\",\n \"description\": \"現在時刻、タイムゾーン変換、日付計算を提供する時間クエリツール\"\n },\n \"memory\": {\n \"name\": \"@modelcontextprotocol/server-memory\",\n \"description\": \"エンティティ・関係・観測を扱うナレッジグラフ型メモリ。会話の重要情報を AI に記憶させます\"\n },\n \"sequential-thinking\": {\n \"name\": \"@modelcontextprotocol/server-sequential-thinking\",\n \"description\": \"複雑な問題をステップに分解して深く考えるためのシーケンシャル思考ツール\"\n },\n \"context7\": {\n \"name\": \"@upstash/context7-mcp\",\n \"description\": \"最新のライブラリドキュメントとコード例を提供する Context7 ドキュメント検索ツール。キー設定で上限が拡張されます\"\n }\n }\n },\n \"prompts\": {\n \"manage\": \"プロンプト\",\n \"title\": \"{{appName}} プロンプト管理\",\n \"claudeTitle\": \"Claude プロンプト管理\",\n \"codexTitle\": \"Codex プロンプト管理\",\n \"add\": \"プロンプトを追加\",\n \"edit\": \"プロンプトを編集\",\n \"addTitle\": \"{{appName}} プロンプトを追加\",\n \"editTitle\": \"{{appName}} プロンプトを編集\",\n \"import\": \"既存をインポート\",\n \"count\": \"{{count}} 件のプロンプト\",\n \"enabled\": \"有効\",\n \"enable\": \"有効化\",\n \"enabledName\": \"有効: {{name}}\",\n \"noneEnabled\": \"有効なプロンプトがありません\",\n \"currentFile\": \"現在の {{filename}} の内容\",\n \"empty\": \"まだプロンプトがありません\",\n \"emptyDescription\": \"上のボタンからプロンプトを追加またはインポートしてください\",\n \"loading\": \"読み込み中...\",\n \"name\": \"名前\",\n \"namePlaceholder\": \"例: デフォルトプロジェクトプロンプト\",\n \"description\": \"説明\",\n \"descriptionPlaceholder\": \"任意の説明\",\n \"content\": \"内容\",\n \"contentPlaceholder\": \"# {{filename}}\\n\\nここにプロンプト内容を入力...\",\n \"loadFailed\": \"プロンプトの読み込みに失敗しました\",\n \"saveSuccess\": \"保存しました\",\n \"saveFailed\": \"保存に失敗しました\",\n \"deleteSuccess\": \"削除しました\",\n \"deleteFailed\": \"削除に失敗しました\",\n \"enableSuccess\": \"有効化しました\",\n \"enableFailed\": \"有効化に失敗しました\",\n \"disableSuccess\": \"無効化しました\",\n \"disableFailed\": \"無効化に失敗しました\",\n \"importSuccess\": \"インポートしました\",\n \"importFailed\": \"インポートに失敗しました\",\n \"confirm\": {\n \"deleteTitle\": \"削除の確認\",\n \"deleteMessage\": \"プロンプト「{{name}}」を削除してもよろしいですか?\"\n }\n },\n \"profiles\": {\n \"switcherTooltip\": {\n \"claude\": \"プロジェクト:Claude Code のプロバイダー / MCP / Skills / メモリファイルをワンクリックで切り替え\",\n \"claude-desktop\": \"プロジェクト:Claude Desktop のプロバイダーをワンクリックで切り替え\",\n \"codex\": \"プロジェクト:Codex のプロバイダー / MCP / Skills / メモリファイルをワンクリックで切り替え\"\n },\n \"none\": \"プロジェクトを使用しない\",\n \"searchPlaceholder\": \"プロジェクトを検索\",\n \"empty\": \"プロジェクトはまだありません\",\n \"createFromCurrent\": \"新規プロジェクト\",\n \"createDescription\": {\n \"claude\": \"Claude Code の現在のプロバイダー、MCP、Skills、メモリファイルをプロジェクトとして保存し、後からワンクリックで切り替えられます。\",\n \"claude-desktop\": \"Claude Desktop の現在のプロバイダーをプロジェクトとして保存し、後からワンクリックで切り替えられます。\",\n \"codex\": \"Codex の現在のプロバイダー、MCP、Skills、メモリファイルの設定をプロジェクトとして保存し、後からワンクリックで切り替えられます。\"\n },\n \"manage\": \"プロジェクトを管理…\",\n \"manageTitle\": \"プロジェクト管理\",\n \"manageDescription\": \"プロジェクトは設定のスナップショットです。プロジェクトを切り替えると、現在の設定は元のプロジェクトに自動的に保存されます。\",\n \"namePlaceholder\": \"例:開発、イラスト\",\n \"rename\": \"名前を変更\",\n \"noSnapshotForScope\": \"このアプリ側は未保存\",\n \"delete\": \"削除\",\n \"deleteConfirmTitle\": \"削除の確認\",\n \"deleteConfirmMessage\": \"プロジェクト「{{name}}」を削除してもよろしいですか?現在の設定は変更されません。\",\n \"createSuccess\": \"プロジェクトを作成しました\",\n \"createFailed\": \"プロジェクトの作成に失敗しました: {{detail}}\",\n \"updateSuccess\": \"プロジェクトを更新しました\",\n \"updateFailed\": \"プロジェクトの更新に失敗しました: {{detail}}\",\n \"deleteSuccess\": \"プロジェクトを削除しました\",\n \"deleteFailed\": \"プロジェクトの削除に失敗しました: {{detail}}\",\n \"applySuccess\": \"プロジェクトを適用しました\",\n \"applyFailed\": \"プロジェクトの適用に失敗しました: {{detail}}\",\n \"applyWarnings\": \"プロジェクトを適用しました(警告 {{warningCount}} 件):\\n{{details}}\",\n \"clearSuccess\": \"プロジェクトを使用しない状態に切り替えました\"\n },\n \"workspace\": {\n \"title\": \"ワークスペースファイル\",\n \"manage\": \"ワークスペース\",\n \"files\": {\n \"agents\": \"エージェントの操作指示とルール\",\n \"soul\": \"エージェントの人格とコミュニケーションスタイル\",\n \"user\": \"ユーザープロファイルと設定\",\n \"identity\": \"エージェントの名前とアバター\",\n \"tools\": \"ローカルツールドキュメント\",\n \"memory\": \"長期記憶と意思決定記録\",\n \"heartbeat\": \"ハートビート実行チェックリスト\",\n \"bootstrap\": \"初回実行ブートストラップ\",\n \"boot\": \"ゲートウェイ再起動チェックリスト\"\n },\n \"editing\": \"{{filename}} を編集\",\n \"saveSuccess\": \"保存しました\",\n \"saveFailed\": \"保存に失敗しました\",\n \"loadFailed\": \"読み込みに失敗しました\",\n \"openDirectory\": \"ファイルマネージャーで開く\",\n \"dailyMemory\": {\n \"title\": \"デイリーメモリー\",\n \"sectionTitle\": \"デイリーメモリー\",\n \"cardTitle\": \"デイリーメモリーファイル\",\n \"cardDescription\": \"デイリーメモリーの閲覧・管理\",\n \"createToday\": \"メモリーを追加\",\n \"empty\": \"デイリーメモリーファイルはまだありません\",\n \"loadFailed\": \"デイリーメモリーファイルの読み込みに失敗しました\",\n \"createFailed\": \"デイリーメモリーファイルの作成に失敗しました\",\n \"deleteSuccess\": \"デイリーメモリーファイルを削除しました\",\n \"deleteFailed\": \"デイリーメモリーファイルの削除に失敗しました\",\n \"confirmDeleteTitle\": \"デイリーメモリーを削除\",\n \"confirmDeleteMessage\": \"{{date}} のデイリーメモリーを削除しますか?この操作は取り消せません。\",\n \"searchPlaceholder\": \"全文検索...\",\n \"searchScopeHint\": \"すべてのデイリーメモリーを全文検索 ⌘F\",\n \"searchCloseHint\": \"Escで閉じる\",\n \"noSearchResults\": \"検索に一致するデイリーメモリーはありません。\",\n \"searching\": \"検索中...\",\n \"searchFailed\": \"検索に失敗しました\",\n \"matchCount\": \"{{count}}件一致\"\n }\n },\n \"openclaw\": {\n \"backupCreated\": \"バックアップを作成しました: {{path}}\",\n \"providerKey\": \"プロバイダーキー\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定ファイル内のユニーク識別子。小文字、数字、ハイフンのみ使用可能。\",\n \"providerKeyLockedHint\": \"このプロバイダーは既にアプリ設定へ追加されているため、キーは変更できません。\",\n \"providerKeyRequired\": \"プロバイダーキーを入力してください\",\n \"providerKeyDuplicate\": \"このキーは既に使用されています\",\n \"providerKeyInvalid\": \"無効な形式です。小文字、数字、ハイフンのみ使用可能。\",\n \"apiProtocol\": \"API プロトコル\",\n \"selectProtocol\": \"API プロトコルを選択\",\n \"apiProtocolHint\": \"プロバイダーの API と互換性のあるプロトコルタイプを選択してください。ほとんどのプロバイダーは OpenAI Completions 形式を使用します。\",\n \"baseUrl\": \"API エンドポイント\",\n \"baseUrlHint\": \"プロバイダーの API エンドポイントアドレス。\",\n \"models\": \"モデル一覧\",\n \"addModel\": \"モデルを追加\",\n \"noModels\": \"モデルが設定されていません。「モデルを追加」をクリックして利用可能なモデルを設定してください。\",\n \"modelId\": \"モデル ID\",\n \"modelIdPlaceholder\": \"claude-3-sonnet\",\n \"modelName\": \"表示名\",\n \"modelNamePlaceholder\": \"Claude 3 Sonnet\",\n \"contextWindow\": \"コンテキストウィンドウ\",\n \"maxTokens\": \"最大出力トークン数\",\n \"reasoning\": \"推論モード\",\n \"reasoningOn\": \"有効\",\n \"reasoningOff\": \"無効\",\n \"inputTypes\": \"入力タイプ\",\n \"inputCost\": \"入力コスト ($/M トークン)\",\n \"outputCost\": \"出力コスト ($/M トークン)\",\n \"advancedOptions\": \"詳細オプション\",\n \"cacheReadCost\": \"キャッシュ読取コスト ($/M トークン)\",\n \"cacheWriteCost\": \"キャッシュ書込コスト ($/M トークン)\",\n \"cacheCostHint\": \"キャッシュコストは Prompt Caching のコスト計算に使用されます。キャッシュを使用しない場合は空欄のままにしてください。\",\n \"modelsHint\": \"このプロバイダーがサポートするモデルを設定します。モデル ID は API 呼び出しに、表示名はインターフェースに使用されます。\",\n \"userAgent\": \"User-Agent を送信\",\n \"userAgentHint\": \"一部のプロバイダーはブラウザの User-Agent ヘッダーが必要です。\",\n \"env\": {\n \"title\": \"環境変数\",\n \"description\": \"openclaw.json の環境変数設定を管理(APIキー、カスタム変数など)\",\n \"editorHint\": \"env セクション全体を JSON として編集します。env.vars や env.shellEnv などのネストしたオブジェクトにも対応しています。\",\n \"objectRequired\": \"OpenClaw の env は JSON オブジェクトである必要があります。\",\n \"invalidJson\": \"OpenClaw の env は有効な JSON である必要があります。\",\n \"empty\": \"OpenClaw の env は空にできません。空オブジェクトの場合は {} を使用してください。\",\n \"keyPlaceholder\": \"変数名\",\n \"valuePlaceholder\": \"値\",\n \"add\": \"変数を追加\",\n \"saveSuccess\": \"環境変数を保存しました\",\n \"saveFailed\": \"環境変数の保存に失敗しました\",\n \"loadFailed\": \"環境変数の読み込みに失敗しました\",\n \"duplicateKey\": \"重複する変数名が検出されました: {{key}}\"\n },\n \"tools\": {\n \"title\": \"ツール権限\",\n \"description\": \"openclaw.json のツール権限設定を管理(許可/拒否リスト)\",\n \"profile\": \"権限プロファイル\",\n \"profileMinimal\": \"最小\",\n \"profileCoding\": \"コーディング\",\n \"profileMessaging\": \"メッセージ\",\n \"profileFull\": \"フルアクセス\",\n \"profileUnset\": \"未設定\",\n \"unsupportedProfileTitle\": \"未対応のツールプロファイルを検出しました\",\n \"unsupportedProfileDescription\": \"現在の tools.profile の値 '{{value}}' は OpenClaw の対応リストにありません。新しい値を選択するまでこの値を保持します。\",\n \"unsupportedProfileLabel\": \"未対応\",\n \"allowList\": \"許可リスト\",\n \"denyList\": \"拒否リスト\",\n \"patternPlaceholder\": \"ツール名またはパターン\",\n \"addAllow\": \"許可を追加\",\n \"addDeny\": \"拒否を追加\",\n \"saveSuccess\": \"ツール権限を保存しました\",\n \"saveFailed\": \"ツール権限の保存に失敗しました\",\n \"loadFailed\": \"ツール権限の読み込みに失敗しました\"\n },\n \"agents\": {\n \"title\": \"Agents 設定\",\n \"description\": \"openclaw.json の agents.defaults 設定を管理(デフォルトモデル、ランタイムパラメータなど)\",\n \"modelSection\": \"モデル設定\",\n \"primaryModel\": \"デフォルトモデル\",\n \"primaryModelHint\": \"設定済みプロバイダのモデルから選択してください\",\n \"notSet\": \"未設定\",\n \"fallbackModels\": \"フォールバックモデル\",\n \"fallbackModelsHint\": \"プライマリモデルが利用不可の場合、優先度順にフォールバックが試行されます\",\n \"addFallback\": \"フォールバックモデルを追加\",\n \"noModels\": \"設定済みのプロバイダモデルがありません。先に OpenClaw プロバイダを追加してください。\",\n \"notInList\": \"{{value}} (未設定)\",\n \"runtimeSection\": \"ランタイムパラメータ\",\n \"workspace\": \"ワークスペースパス\",\n \"timeout\": \"タイムアウト(秒)\",\n \"contextTokens\": \"コンテキストトークン数\",\n \"maxConcurrent\": \"最大同時実行数\",\n \"legacyTimeoutTitle\": \"旧タイムアウト設定を検出しました\",\n \"legacyTimeoutDescription\": \"この設定ではまだ agents.defaults.timeout を使用しています。ここで保存すると timeoutSeconds に移行されます。\",\n \"saveSuccess\": \"Agents 設定を保存しました\",\n \"saveFailed\": \"Agents 設定の保存に失敗しました\",\n \"loadFailed\": \"Agents 設定の読み込みに失敗しました\"\n },\n \"health\": {\n \"title\": \"OpenClaw 設定の警告を検出しました\",\n \"invalidToolsProfile\": \"tools.profile に未対応の値が設定されています。OpenClaw が現在サポートしているのは minimal、coding、messaging、full です。\",\n \"legacyTimeout\": \"agents.defaults.timeout は非推奨です。Agents パネルを保存すると timeoutSeconds に移行されます。\",\n \"stringifiedEnvVars\": \"env.vars はオブジェクトである必要がありますが、現在の値は文字列化または破損しているようです。\",\n \"stringifiedShellEnv\": \"env.shellEnv はオブジェクトである必要がありますが、現在の値は文字列化または破損しているようです。\",\n \"parseFailed\": \"openclaw.json を有効な JSON5 として解析できませんでした。ここで編集する前にファイルを修正してください。\"\n },\n \"primaryModel\": \"プライマリモデル\",\n \"fallbackModel\": \"フォールバックモデル\"\n },\n \"hermes\": {\n \"form\": {\n \"baseUrl\": \"API エンドポイント\",\n \"baseUrlHint\": \"プロバイダーの API エンドポイント URL。\",\n \"providerKey\": \"プロバイダーキー\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"小文字、数字、ハイフンのみ。config.yaml のプロバイダー名として使用されます。\",\n \"providerKeyLockedHint\": \"このプロバイダーは Hermes 設定に追加済みのため、キーは変更できません。\",\n \"providerKeyRequired\": \"プロバイダーキーは必須です\",\n \"providerKeyInvalid\": \"プロバイダーキーは小文字、数字、ハイフンのみ使用できます\",\n \"providerKeyDuplicate\": \"このプロバイダーキーは既に存在します\",\n \"apiMode\": \"API モード\",\n \"apiModeHint\": \"プロバイダーの API プロトコル。エンドポイントに合わせて正しい形式を選択してください。\",\n \"apiModeChatCompletions\": \"OpenAI Chat Completions\",\n \"apiModeAnthropicMessages\": \"Anthropic Messages\",\n \"apiModeCodexResponses\": \"OpenAI Responses\",\n \"apiModeBedrockConverse\": \"AWS Bedrock Converse\",\n \"baseUrlRequired\": \"API エンドポイントは必須です\",\n \"baseUrlScheme\": \"http:// または https:// で始まるアドレスを指定してください\",\n \"baseUrlInvalid\": \"API エンドポイントは有効な URL ではありません\",\n \"models\": \"モデル一覧\",\n \"addModel\": \"モデルを追加\",\n \"noModels\": \"モデル未設定。このプロバイダーに切り替えてもデフォルトモデルは更新されません。\",\n \"modelId\": \"モデル ID\",\n \"modelIdPlaceholder\": \"anthropic/claude-opus-4-8\",\n \"modelName\": \"表示名\",\n \"modelNamePlaceholder\": \"Claude Opus 4.8\",\n \"contextLength\": \"コンテキスト長\",\n \"advancedOptions\": \"詳細オプション\",\n \"modelsHint\": \"切り替え時、最初のモデルが model.default に書き込まれます。\",\n \"primaryModel\": \"デフォルト\",\n \"fallbackModel\": \"予備\",\n \"providerAdvanced\": \"プロバイダー詳細オプション\",\n \"rateLimitDelay\": \"リクエスト間隔(秒)\",\n \"rateLimitDelayHint\": \"連続したリクエスト間の最小待機秒数(任意)。空欄の場合は制限なし。\"\n },\n \"webui\": {\n \"open\": \"Hermes Web UI を開く\",\n \"offline\": \"Hermes Web UI が起動していません。まず `hermes dashboard` を実行してください。\",\n \"openFailed\": \"Hermes Web UI を開けませんでした\",\n \"launchConfirmTitle\": \"Hermes Dashboard が起動していません\",\n \"launchConfirmMessage\": \"ターミナルを開いて `hermes dashboard` を実行しますか?\\n\\n起動完了後、ブラウザが自動的に開きます。\\n\\nターミナルに `hermes` が見つからない、または web 依存が不足するというエラーが表示される場合は、まず次を実行してください:\\npip install hermes-agent[web]\",\n \"launchConfirmAction\": \"ターミナルを開いて起動\",\n \"launching\": \"ターミナルで hermes dashboard を起動しました。\",\n \"launchFailed\": \"ターミナルを開けませんでした\"\n },\n \"memory\": {\n \"title\": \"メモリ\",\n \"agentTab\": \"エージェント記憶 (MEMORY.md)\",\n \"userTab\": \"ユーザープロファイル (USER.md)\",\n \"usage\": \"{{current}} / {{limit}} 文字\",\n \"overLimit\": \"上限を超えました。Hermes は次回読み込み時に切り詰めます\",\n \"enableOn\": \"有効\",\n \"enableOff\": \"無効\",\n \"disabledHint\": \"再度有効化するまで Hermes はこの記憶をスキップします\",\n \"toggleFailed\": \"メモリの切り替えに失敗しました\",\n \"saveSuccess\": \"記憶を保存しました\",\n \"saveFailed\": \"記憶の保存に失敗しました\",\n \"loadFailed\": \"記憶ファイルの読み込みに失敗しました\",\n \"openConfig\": \"Hermes Web UI で上限を調整\",\n \"runtimeNote\": \"変更は Hermes の再起動または新規セッション時に反映されます。\"\n }\n },\n \"env\": {\n \"warning\": {\n \"title\": \"競合する環境変数を検出しました\",\n \"description\": \"設定を上書きする可能性のある環境変数を {{count}} 件見つけました\"\n },\n \"actions\": {\n \"expand\": \"詳細を表示\",\n \"collapse\": \"折りたたむ\",\n \"selectAll\": \"すべて選択\",\n \"clearSelection\": \"選択を解除\",\n \"deleteSelected\": \"選択 {{count}} 件を削除\",\n \"deleting\": \"削除中...\"\n },\n \"field\": {\n \"value\": \"値\",\n \"source\": \"ソース\"\n },\n \"source\": {\n \"userRegistry\": \"ユーザー環境変数(レジストリ)\",\n \"systemRegistry\": \"システム環境変数(レジストリ)\",\n \"systemEnv\": \"システム環境変数\"\n },\n \"delete\": {\n \"success\": \"環境変数を削除しました\",\n \"error\": \"環境変数の削除に失敗しました\"\n },\n \"backup\": {\n \"location\": \"バックアップ場所: {{path}}\"\n },\n \"confirm\": {\n \"title\": \"環境変数を削除しますか?\",\n \"message\": \"{{count}} 件の環境変数を削除してもよろしいですか?\",\n \"backupNotice\": \"削除前に自動バックアップを作成します。後で復元できます。再起動またはターミナル再起動後に反映されます。\",\n \"confirm\": \"削除を確認\"\n },\n \"error\": {\n \"noSelection\": \"削除する環境変数を選択してください\"\n }\n },\n \"skills\": {\n \"manage\": \"Skills\",\n \"title\": \"Skills 管理\",\n \"description\": \"人気リポジトリからスキルを探してインストールし、Claude Code/Codex/Gemini を拡張\",\n \"refresh\": \"更新\",\n \"refreshing\": \"更新中...\",\n \"repoManager\": \"リポジトリ管理\",\n \"count\": \"{{count}} 個のスキル\",\n \"empty\": \"スキルがありません\",\n \"emptyDescription\": \"スキルリポジトリを追加して探索してください\",\n \"addRepo\": \"スキルリポジトリを追加\",\n \"loading\": \"読み込み中...\",\n \"installed\": \"インストール済み\",\n \"install\": \"インストール\",\n \"installing\": \"インストール中...\",\n \"uninstall\": \"アンインストール\",\n \"uninstalling\": \"アンインストール中...\",\n \"view\": \"表示\",\n \"noDescription\": \"説明なし\",\n \"loadFailed\": \"読み込みに失敗しました\",\n \"installSuccess\": \"スキル {{name}} をインストールしました\",\n \"installFailed\": \"インストールに失敗しました\",\n \"uninstallSuccess\": \"スキル {{name}} をアンインストールしました\",\n \"uninstallFailed\": \"アンインストールに失敗しました\",\n \"update\": \"更新\",\n \"updating\": \"更新中...\",\n \"updateAvailable\": \"更新あり\",\n \"updateSuccess\": \"スキル {{name}} を最新バージョンに更新しました\",\n \"updateFailed\": \"更新に失敗しました\",\n \"checkUpdates\": \"更新を確認\",\n \"checkingUpdates\": \"確認中...\",\n \"noUpdates\": \"すべてのスキルは最新です\",\n \"updatesFound\": \"{{count}} 個のスキルに更新があります\",\n \"updateAll\": \"すべて更新 ({{count}})\",\n \"updatingAll\": \"更新中...\",\n \"updateAllSuccess\": \"{{count}} 個のスキルを更新しました\",\n \"error\": {\n \"skillNotFound\": \"スキルが見つかりません: {{directory}}\",\n \"missingRepoInfo\": \"リポジトリ情報(owner または name)が不足しています\",\n \"downloadTimeout\": \"リポジトリ {{owner}}/{{name}} のダウンロードがタイムアウトしました({{timeout}} 秒)\",\n \"downloadTimeoutHint\": \"ネットワークを確認するか、時間をおいて再試行してください\",\n \"skillPathNotFound\": \"リポジトリ {{owner}}/{{name}} にスキルパス '{{path}}' がありません\",\n \"skillDirNotFound\": \"スキルディレクトリが見つかりません: {{path}}\",\n \"directoryConflict\": \"スキルディレクトリ '{{directory}}' は既に {{existing_repo}} で使用されています。{{new_repo}} からインストールできません\",\n \"emptyArchive\": \"ダウンロードしたアーカイブが空です\",\n \"downloadFailed\": \"ダウンロードに失敗しました: HTTP {{status}}\",\n \"allBranchesFailed\": \"すべてのブランチで失敗しました。試行: {{branches}}\",\n \"httpError\": \"HTTP エラー {{status}}\",\n \"http403\": \"GitHub へのアクセスが制限されています(レート制限の可能性)\",\n \"http404\": \"リポジトリまたはブランチが見つかりません。URL を確認してください\",\n \"http429\": \"リクエストが多すぎます。時間をおいて再試行してください\",\n \"parseMetadataFailed\": \"スキルメタデータの解析に失敗しました\",\n \"getHomeDirFailed\": \"ユーザーのホームディレクトリを取得できません\",\n \"noSkillsInZip\": \"ZIP ファイルにスキルが見つかりません(SKILL.md ファイルが必要です)\",\n \"networkError\": \"ネットワークエラー\",\n \"fsError\": \"ファイルシステムエラー\",\n \"unknownError\": \"不明なエラー\",\n \"suggestion\": {\n \"checkNetwork\": \"ネットワーク接続を確認してください\",\n \"checkProxy\": \"HTTP プロキシの設定を検討してください\",\n \"retryLater\": \"時間をおいて再試行してください\",\n \"checkRepoUrl\": \"リポジトリ URL とブランチ名を確認してください\",\n \"checkDiskSpace\": \"ディスク容量を確認してください\",\n \"checkPermission\": \"ディレクトリの権限を確認してください\",\n \"uninstallFirst\": \"同名のスキルを先にアンインストールしてください\",\n \"checkZipContent\": \"ZIP ファイルに有効なスキルディレクトリ(SKILL.md を含む)が含まれていることを確認してください\"\n }\n },\n \"repo\": {\n \"title\": \"スキルリポジトリを管理\",\n \"description\": \"GitHub のスキルリポジトリソースを追加または削除します\",\n \"url\": \"リポジトリ URL\",\n \"urlPlaceholder\": \"owner/name または https://github.com/owner/name\",\n \"branch\": \"ブランチ\",\n \"branchPlaceholder\": \"main\",\n \"path\": \"スキルパス\",\n \"pathPlaceholder\": \"skills(任意。空欄はルート)\",\n \"add\": \"リポジトリを追加\",\n \"list\": \"追加済みリポジトリ\",\n \"empty\": \"リポジトリがありません\",\n \"invalidUrl\": \"リポジトリ URL の形式が無効です\",\n \"addSuccess\": \"リポジトリ {{owner}}/{{name}} を追加しました。検出スキル: {{count}} 件\",\n \"addFailed\": \"追加に失敗しました\",\n \"removeSuccess\": \"リポジトリ {{owner}}/{{name}} を削除しました\",\n \"removeFailed\": \"削除に失敗しました\",\n \"skillCount\": \"{{count}} 件のスキルを検出\"\n },\n \"search\": \"スキルを検索\",\n \"searchPlaceholder\": \"スキル名またはリポジトリで検索...\",\n \"searchSource\": {\n \"repos\": \"リポジトリ\",\n \"skillssh\": \"skills.sh\"\n },\n \"skillssh\": {\n \"searchPlaceholder\": \"skills.sh を検索(2文字以上)...\",\n \"installs\": \"{{count}} インストール\",\n \"loadMore\": \"さらに読み込む\",\n \"loading\": \"skills.sh を検索中...\",\n \"noResults\": \"\\\"{{query}}\\\" に該当するスキルがありません\",\n \"error\": \"skills.sh の検索に失敗しました\",\n \"poweredBy\": \"Powered by skills.sh\"\n },\n \"filter\": {\n \"placeholder\": \"状態で絞り込み\",\n \"all\": \"すべて\",\n \"installed\": \"インストール済み\",\n \"uninstalled\": \"未インストール\",\n \"repo\": \"リポジトリで絞り込み\",\n \"allRepos\": \"すべてのリポジトリ\"\n },\n \"noResults\": \"一致するスキルが見つかりませんでした\",\n \"noInstalled\": \"インストールされたスキルがありません\",\n \"noInstalledDescription\": \"リポジトリからスキルを発見してインストールするか、既存のスキルをインポートしてください\",\n \"discover\": \"スキルを発見\",\n \"import\": \"既存をインポート\",\n \"importDescription\": \"CC Switch 統合管理にインポートするスキルを選択してください\",\n \"importSuccess\": \"{{count}} 件のスキルをインポートしました\",\n \"importSelected\": \"選択をインポート ({{count}})\",\n \"noUnmanagedFound\": \"インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。\",\n \"unmanagedAvailable\": \"インポート可能なスキルがあります\",\n \"foundIn\": \"発見場所\",\n \"local\": \"ローカル\",\n \"uninstallConfirm\": \"「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除され、削除前にローカルバックアップが自動作成されます。\",\n \"uninstallInMainPanel\": \"メインパネルからスキルをアンインストールしてください\",\n \"notFound\": \"スキルが見つかりません\",\n \"backup\": {\n \"location\": \"バックアップ場所: {{path}}\"\n },\n \"restoreFromBackup\": {\n \"button\": \"バックアップから復元\",\n \"title\": \"バックアップから復元\",\n \"description\": \"Skills バックアップを選択して、ローカルにファイルを復元し、現在の一覧へ戻します。\",\n \"empty\": \"復元できる Skills バックアップはありません\",\n \"createdAt\": \"バックアップ日時\",\n \"path\": \"バックアップパス\",\n \"restore\": \"復元\",\n \"restoring\": \"復元中...\",\n \"delete\": \"削除\",\n \"deleting\": \"削除中...\",\n \"deleteSuccess\": \"スキルバックアップ {{name}} を削除しました\",\n \"deleteFailed\": \"スキルバックアップの削除に失敗しました\",\n \"deleteConfirmTitle\": \"バックアップを削除\",\n \"deleteConfirmMessage\": \"スキルバックアップ「{{name}}」を削除しますか?この操作は元に戻せません。\",\n \"success\": \"スキル {{name}} をバックアップから復元しました\",\n \"failed\": \"バックアップからの復元に失敗しました\"\n },\n \"apps\": {\n \"claude\": \"Claude\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\",\n \"openclaw\": \"OpenClaw\"\n },\n \"installFromZip\": {\n \"button\": \"ZIP からインストール\",\n \"installing\": \"インストール中...\",\n \"successSingle\": \"スキル {{name}} をインストールしました\",\n \"successMultiple\": \"{{count}} 件のスキルをインストールしました\",\n \"noSkillsFound\": \"ZIP ファイルにスキルが見つかりません(SKILL.md が必要です)\"\n }\n },\n \"deeplink\": {\n \"confirmImport\": \"プロバイダーのインポートを確認\",\n \"confirmImportDescription\": \"次の設定をディープリンクから CC Switch へインポートします\",\n \"importPrompt\": \"プロンプトをインポート\",\n \"importPromptDescription\": \"このシステムプロンプトをインポートするか確認してください\",\n \"importMcp\": \"MCP サーバーをインポート\",\n \"importMcpDescription\": \"これらの MCP サーバーをインポートするか確認してください\",\n \"importSkill\": \"スキルリポジトリを追加\",\n \"importSkillDescription\": \"このスキルリポジトリを追加するか確認してください\",\n \"promptImportSuccess\": \"プロンプトをインポートしました\",\n \"promptImportSuccessDescription\": \"インポートされたプロンプト: {{name}}\",\n \"mcpImportSuccess\": \"MCP サーバーをインポートしました\",\n \"mcpImportSuccessDescription\": \"{{count}} 件のサーバーをインポートしました\",\n \"mcpPartialSuccess\": \"一部のみインポート成功\",\n \"mcpPartialSuccessDescription\": \"成功: {{success}}、失敗: {{failed}}\",\n \"skillImportSuccess\": \"スキルリポジトリを追加しました\",\n \"skillImportSuccessDescription\": \"追加したリポジトリ: {{repo}}\",\n \"app\": \"アプリ種別\",\n \"providerName\": \"プロバイダー名\",\n \"homepage\": \"ホームページ\",\n \"endpoint\": \"API エンドポイント\",\n \"apiKey\": \"API Key\",\n \"icon\": \"アイコン\",\n \"model\": \"モデル\",\n \"haikuModel\": \"Haiku モデル\",\n \"sonnetModel\": \"Sonnet モデル\",\n \"opusModel\": \"Opus モデル\",\n \"multiModel\": \"マルチモーダルモデル\",\n \"notes\": \"メモ\",\n \"import\": \"インポート\",\n \"importing\": \"インポート中...\",\n \"warning\": \"インポート前に内容を確認してください。後から一覧で編集・削除できます。\",\n \"parseError\": \"ディープリンクの解析に失敗しました\",\n \"importSuccess\": \"インポート成功\",\n \"importSuccessDescription\": \"プロバイダー「{{name}}」をインポートしました\",\n \"importError\": \"インポートに失敗しました\",\n \"configSource\": \"設定ソース\",\n \"configEmbedded\": \"埋め込み設定\",\n \"configRemote\": \"リモート設定\",\n \"configDetails\": \"設定の詳細\",\n \"configUrl\": \"設定ファイル URL\",\n \"configMergeError\": \"設定ファイルのマージに失敗しました\",\n \"primaryEndpoint\": \"メイン\",\n \"mcp\": {\n \"title\": \"MCP サーバーを一括インポート\",\n \"targetApps\": \"ターゲットアプリ\",\n \"serverCount\": \"MCP サーバー({{count}} 件)\",\n \"enabledWarning\": \"インポート後、指定したすべてのアプリに即座に書き込まれます\"\n },\n \"prompt\": {\n \"title\": \"システムプロンプトをインポート\",\n \"app\": \"アプリ\",\n \"name\": \"名前\",\n \"description\": \"説明\",\n \"contentPreview\": \"内容プレビュー\",\n \"enabledWarning\": \"インポート後すぐにこのプロンプトが有効になり、他は無効になります\"\n },\n \"skill\": {\n \"title\": \"Claude スキルリポジトリを追加\",\n \"repo\": \"GitHub リポジトリ\",\n \"directory\": \"対象ディレクトリ\",\n \"branch\": \"ブランチ\",\n \"skillsPath\": \"スキルパス\",\n \"hint\": \"この操作でスキルリポジトリが一覧に追加されます。\",\n \"hintDetail\": \"追加後、スキル管理ページから個別のスキルをインストールできます。\"\n },\n \"usageScript\": \"使用量クエリ\",\n \"usageScriptEnabled\": \"有効\",\n \"usageScriptDisabled\": \"無効\",\n \"usageApiKey\": \"使用量 API キー\",\n \"usageBaseUrl\": \"使用量クエリ URL\",\n \"usageAutoInterval\": \"自動クエリ\",\n \"usageAutoIntervalValue\": \"{{minutes}} 分ごと\"\n },\n \"iconPicker\": {\n \"search\": \"アイコンを検索\",\n \"searchPlaceholder\": \"アイコン名を入力...\",\n \"noResults\": \"一致するアイコンが見つかりません\",\n \"category\": {\n \"aiProvider\": \"AI プロバイダー\",\n \"cloud\": \"クラウドプラットフォーム\",\n \"tool\": \"開発ツール\",\n \"other\": \"その他\"\n }\n },\n \"providerIcon\": {\n \"label\": \"アイコン\",\n \"colorLabel\": \"アイコンカラー\",\n \"selectIcon\": \"アイコンを選択\",\n \"preview\": \"プレビュー\",\n \"clickToChange\": \"クリックでアイコンを変更\",\n \"clickToSelect\": \"クリックでアイコンを選択\",\n \"color\": \"アイコンカラー\"\n },\n \"migration\": {\n \"success\": \"設定の移行が完了しました\",\n \"skillsSuccess\": \"スキルを {{count}} 件、自動的に統合管理へインポートしました\",\n \"skillsFailed\": \"スキルの自動インポートに失敗しました\",\n \"skillsFailedDescription\": \"Skills 画面で「既存をインポート」をクリックして手動でインポートしてください(または再起動して再試行)。\"\n },\n \"agents\": {\n \"title\": \"エージェント\"\n },\n \"health\": {\n \"operational\": \"正常\",\n \"degraded\": \"低下\",\n \"failed\": \"失敗\",\n \"circuitOpen\": \"サーキットオープン\",\n \"consecutiveFailures\": \"{{count}} 回連続失敗\"\n },\n \"failover\": {\n \"enabled\": \"{{app}} フェイルオーバーが有効になりました\",\n \"disabled\": \"{{app}} フェイルオーバーが無効になりました\",\n \"toggleFailed\": \"操作に失敗しました: {{detail}}\",\n \"inQueue\": \"キュー内\",\n \"addQueue\": \"追加\",\n \"priority\": {\n \"tooltip\": \"フェイルオーバー優先度 {{priority}}\"\n },\n \"tooltip\": {\n \"enabled\": \"{{app}} フェイルオーバーが有効\\nキューの優先度(P1→P2→...)で使用します\",\n \"disabled\": \"{{app}} フェイルオーバーを有効にする\\nキューの P1 に即時切替し、失敗時は次を順に試行します\"\n }\n },\n \"proxy\": {\n \"panel\": {\n \"serviceAddress\": \"サービスアドレス\",\n \"addressCopied\": \"アドレスをコピーしました\",\n \"currentProvider\": \"現在のプロバイダー:\",\n \"waitingFirstRequest\": \"現在のプロバイダー: 最初のリクエスト待ち...\",\n \"stoppedTitle\": \"ルーティングサービス停止中\",\n \"stoppedDescription\": \"上のトグルでサービスを開始できます\",\n \"openSettings\": \"ルーティングサービスを設定\",\n \"stats\": {\n \"activeConnections\": \"アクティブ接続\",\n \"totalRequests\": \"総リクエスト数\",\n \"successRate\": \"成功率\",\n \"uptime\": \"稼働時間\"\n }\n },\n \"settings\": {\n \"title\": \"ルーティングサービス設定\",\n \"description\": \"ローカルルーティングサーバーのリッスンアドレス、ポート、実行パラメータを設定します。保存後すぐに反映されます。\",\n \"alert\": {\n \"autoApply\": \"変更は実行中のルーティングサービスに自動的に同期され、手動での再起動は不要です。\"\n },\n \"basic\": {\n \"title\": \"基本設定\",\n \"description\": \"ルーティングサービスのリッスンアドレスとポートを設定します。\"\n },\n \"advanced\": {\n \"title\": \"詳細パラメータ\",\n \"description\": \"リクエストの安定性とログ記録を制御します。\"\n },\n \"timeout\": {\n \"title\": \"タイムアウト設定\",\n \"description\": \"ストリーミングと非ストリーミングリクエストのタイムアウトを設定します。\"\n },\n \"fields\": {\n \"listenAddress\": {\n \"label\": \"リッスンアドレス\",\n \"placeholder\": \"127.0.0.1\",\n \"description\": \"ルーティングサーバーがリッスンするIPアドレス(推奨: 127.0.0.1)\"\n },\n \"listenPort\": {\n \"label\": \"リッスンポート\",\n \"placeholder\": \"15721\",\n \"description\": \"ルーティングサーバーがリッスンするポート番号(1024 ~ 65535)\"\n },\n \"maxRetries\": {\n \"label\": \"最大リトライ回数\",\n \"placeholder\": \"3\",\n \"description\": \"リクエスト失敗時のリトライ回数(0 ~ 10)\"\n },\n \"requestTimeout\": {\n \"label\": \"リクエストタイムアウト(秒)\",\n \"placeholder\": \"0(無制限)または 300\",\n \"description\": \"単一リクエストの最大待機時間(0 = 無制限、または 10 ~ 600 秒)\"\n },\n \"enableLogging\": {\n \"label\": \"ログ記録を有効化\",\n \"description\": \"トラブルシューティングのためにすべてのルーティングリクエストを記録\"\n },\n \"streamingFirstByteTimeout\": {\n \"label\": \"ストリーミング初回バイトタイムアウト(秒)\",\n \"description\": \"最初のデータチャンクを待つ最大時間\"\n },\n \"streamingIdleTimeout\": {\n \"label\": \"ストリーミングアイドルタイムアウト(秒)\",\n \"description\": \"データチャンク間の最大間隔\"\n },\n \"nonStreamingTimeout\": {\n \"label\": \"非ストリーミングタイムアウト(秒)\",\n \"description\": \"非ストリーミングリクエストの総タイムアウト\"\n }\n },\n \"validation\": {\n \"addressInvalid\": \"有効なIPアドレスを入力してください\",\n \"portMin\": \"ポートは1024より大きい必要があります\",\n \"portMax\": \"ポートは65535より小さい必要があります\",\n \"retryMin\": \"リトライ回数は負の値にできません\",\n \"retryMax\": \"リトライ回数は10を超えることはできません\",\n \"timeoutNonNegative\": \"タイムアウトは負の値にできません\",\n \"timeoutMax\": \"タイムアウトは600秒を超えることはできません\",\n \"timeoutRange\": \"0または10-600の間の値を入力してください\",\n \"streamingTimeoutMin\": \"タイムアウトは少なくとも5秒必要です\",\n \"streamingTimeoutMax\": \"タイムアウトは300秒を超えることはできません\"\n },\n \"actions\": {\n \"save\": \"設定を保存\"\n },\n \"toast\": {\n \"saved\": \"ルーティング設定を保存しました\",\n \"saveFailed\": \"保存に失敗しました: {{error}}\"\n },\n \"invalidPort\": \"無効なポートです。1024〜65535 の数値を入力してください\",\n \"invalidAddress\": \"無効なアドレスです。IPv4(例: 127.0.0.1)、IPv6(例: ::1)、または localhost を入力してください\",\n \"configSaved\": \"ルーティング設定を保存しました\",\n \"configSaveFailed\": \"設定の保存に失敗しました\",\n \"restartRequired\": \"アドレスまたはポートの変更を反映するにはルーティングサービスの再起動が必要です\"\n },\n \"switchFailed\": \"切り替えに失敗しました: {{error}}\",\n \"takeover\": {\n \"hint\": \"ルーティングするアプリを選択します。有効にすると、そのアプリのリクエストはローカルルーティング経由で転送されます\",\n \"enabled\": \"{{app}} ルーティング有効\",\n \"disabled\": \"{{app}} ルーティング無効\",\n \"failed\": \"ルーティングの切り替えに失敗しました: {{detail}}\",\n \"tooltip\": {\n \"active\": \"{{appLabel}} がルーティング中 - {{address}}:{{port}}\\nホットスイッチングのためプロバイダを切り替え\",\n \"broken\": \"{{appLabel}} がルーティング中ですが、ルーティングサービスが実行されていません\",\n \"inactive\": \"{{appLabel}} のリクエストをローカルルーティング経由でルーティング\"\n }\n },\n \"failover\": {\n \"proxyRequired\": \"フェイルオーバーを設定するには、ルーティングサービスを先に起動する必要があります\",\n \"autoSwitch\": \"自動フェイルオーバー\",\n \"autoSwitchDescription\": \"有効にするとキューの P1 に即時切り替え、リクエスト失敗時はキュー内の次のプロバイダーを自動で試行します\"\n },\n \"failoverQueue\": {\n \"title\": \"フェイルオーバーキュー\",\n \"description\": \"各アプリのプロバイダーのフェイルオーバー順序を管理します\",\n \"info\": \"自動フェイルオーバーを有効にすると、キューの優先度順(P1 優先)でプロバイダーを使用します。失敗時はキュー内の次のプロバイダーを順に試行します。\",\n \"selectProvider\": \"キューに追加するプロバイダーを選択\",\n \"noAvailableProviders\": \"追加できるプロバイダーがありません\",\n \"empty\": \"フェイルオーバーキューが空です。自動フェイルオーバーを有効にするにはプロバイダーを追加してください。\",\n \"orderHint\": \"キューの順序はホームのプロバイダー一覧の順序と一致します。ホームでドラッグして順序を変更できます。\",\n \"dragHint\": \"ドラッグでフェイルオーバー順序を調整します。番号が小さいほど優先度が高くなります。\",\n \"toggleEnabled\": \"有効/無効\",\n \"addSuccess\": \"フェイルオーバーキューに追加しました\",\n \"addFailed\": \"追加に失敗しました\",\n \"removeSuccess\": \"フェイルオーバーキューから削除しました\",\n \"removeFailed\": \"削除に失敗しました\",\n \"reorderSuccess\": \"キュー順序を更新しました\",\n \"reorderFailed\": \"順序の更新に失敗しました\",\n \"toggleFailed\": \"状態の更新に失敗しました\"\n },\n \"autoFailover\": {\n \"info\": \"フェイルオーバーキューに複数のプロバイダーが設定されている場合、リクエストが失敗すると優先度順に試行します。プロバイダーが連続失敗のしきい値に達すると、サーキットブレーカーが開き、一時的にスキップされます。\",\n \"configSaved\": \"自動フェイルオーバー設定を保存しました\",\n \"configSaveFailed\": \"保存に失敗しました\",\n \"validationFailed\": \"以下のフィールドが有効範囲外です: {{fields}}\",\n \"retrySettings\": \"リトライとタイムアウト設定\",\n \"failureThreshold\": \"失敗しきい値\",\n \"failureThresholdHint\": \"この回数連続で失敗するとサーキットブレーカーが開きます(推奨: 3-10)\",\n \"timeout\": \"回復待ち時間(秒)\",\n \"timeoutHint\": \"サーキットが開いた後、回復を試みるまでの待ち時間(推奨: 30-120)\",\n \"circuitBreakerSettings\": \"サーキットブレーカー設定\",\n \"successThreshold\": \"回復成功しきい値\",\n \"successThresholdHint\": \"半開状態でこの回数成功するとサーキットブレーカーが閉じます\",\n \"errorRate\": \"エラー率しきい値 (%)\",\n \"errorRateHint\": \"この値を超えるとサーキットブレーカーが開きます\",\n \"minRequests\": \"最小リクエスト数\",\n \"minRequestsHint\": \"エラー率を計算する前の最小リクエスト数\",\n \"explanationTitle\": \"仕組み\",\n \"failureThresholdLabel\": \"失敗しきい値\",\n \"failureThresholdExplain\": \"この回数連続で失敗すると、サーキットブレーカーが開き、プロバイダーは一時的に利用不可になります\",\n \"timeoutLabel\": \"回復待ち時間\",\n \"timeoutExplain\": \"サーキットが開いた後、半開状態を試みるまでの待ち時間\",\n \"successThresholdLabel\": \"回復成功しきい値\",\n \"successThresholdExplain\": \"半開状態でこの回数成功するとサーキットブレーカーが閉じ、プロバイダーが再び利用可能になります\",\n \"errorRateLabel\": \"エラー率しきい値\",\n \"errorRateExplain\": \"失敗しきい値に達していなくても、エラー率がこの値を超えるとサーキットブレーカーが開きます\",\n \"maxRetries\": \"最大リトライ回数\",\n \"timeoutSettings\": \"タイムアウト設定\",\n \"streamingFirstByte\": \"ストリーミング最初のバイトタイムアウト\",\n \"streamingIdle\": \"ストリーミングアイドルタイムアウト\",\n \"nonStreaming\": \"非ストリーミングタイムアウト\",\n \"maxRetriesHint\": \"リクエスト失敗時のリトライ回数(0-10)\",\n \"streamingFirstByteHint\": \"最初のデータチャンクを待つ最大時間、範囲 1-120 秒、デフォルト 60 秒\",\n \"streamingIdleHint\": \"データチャンク間の最大間隔、範囲 60-600 秒、0 で無効化(途中停止を防止)\",\n \"nonStreamingHint\": \"非ストリーミングリクエストの合計タイムアウト、範囲 60-1200 秒、デフォルト 600 秒(10 分)\"\n },\n \"logging\": {\n \"enabled\": \"ログ記録が有効になりました\",\n \"disabled\": \"ログ記録が無効になりました\",\n \"failed\": \"ログ状態の切り替えに失敗しました\"\n },\n \"server\": {\n \"started\": \"ルーティングサービスが開始されました - {{address}}:{{port}}\",\n \"startFailed\": \"ルーティングサービスの開始に失敗しました: {{detail}}\"\n },\n \"stoppedWithRestore\": \"ルーティングサービスが停止し、すべてのルーティング設定が復元されました\",\n \"stopWithRestoreFailed\": \"停止に失敗しました: {{detail}}\"\n },\n \"streamCheck\": {\n \"reachable\": \"{{providerName}} は接続可能 ({{responseTimeMs}}ms)\",\n \"reachableSlow\": \"{{providerName}} は接続可能だが低速 ({{responseTimeMs}}ms)\",\n \"unreachable\": \"{{providerName}} に接続できません: {{message}}\",\n \"unreachableHint\": \"接続を確立できませんでした(DNS / 接続 / TLS / タイムアウト)。base_url とネットワークを確認してください。\",\n \"connectivityNote\": \"接続チェックはプロバイダーのアドレスに到達できるかを確認するだけで、実際のモデルリクエストは送信しません。何らかの応答があれば「到達可能」とみなされますが、認証やモデル設定が正しいことを保証するものではありません。\",\n \"configSaved\": \"接続チェック設定を保存しました\",\n \"configSaveFailed\": \"保存に失敗しました\",\n \"checkParams\": \"チェックパラメーター\",\n \"timeout\": \"タイムアウト(秒)\",\n \"maxRetries\": \"最大リトライ回数\",\n \"degradedThreshold\": \"低速判定しきい値(ミリ秒)\",\n \"error\": \"{{providerName}} のチェックでエラーが発生しました: {{error}}\"\n },\n \"proxyConfig\": {\n \"proxyEnabled\": \"ルーティング総スイッチ\",\n \"appTakeover\": \"ルーティング有効\",\n \"perAppConfig\": \"アプリ別設定\",\n \"circuitBreaker\": \"サーキットブレーカー\",\n \"circuitBreakerSettings\": \"サーキットブレーカー設定\",\n \"failureThreshold\": \"失敗閾値\",\n \"successThreshold\": \"回復閾値\",\n \"recoveryTimeout\": \"回復待機時間\",\n \"errorRateThreshold\": \"エラー率閾値\",\n \"minRequests\": \"最小リクエスト数\",\n \"timeoutConfig\": \"タイムアウト設定\",\n \"streamingFirstByte\": \"ストリーミング初回バイトタイムアウト\",\n \"streamingIdle\": \"ストリーミングアイドルタイムアウト\",\n \"nonStreaming\": \"非ストリーミングタイムアウト\"\n },\n \"circuitBreaker\": {\n \"failureThreshold\": \"失敗閾値\",\n \"successThreshold\": \"成功閾値\",\n \"timeoutSeconds\": \"タイムアウト(秒)\",\n \"errorRateThreshold\": \"エラー率閾値 (%)\",\n \"minRequests\": \"最小リクエスト数\",\n \"validationFailed\": \"以下のフィールドが有効範囲外です: {{fields}}\",\n \"configSaved\": \"サーキットブレーカー設定が保存されました\",\n \"saveFailed\": \"保存に失敗しました\",\n \"loading\": \"読み込み中...\",\n \"title\": \"サーキットブレーカー設定\",\n \"description\": \"サーキットブレーカーパラメータを調整して、障害検出と復旧動作を制御します\",\n \"failureThresholdHint\": \"連続失敗後にサーキットブレーカーを開く回数\",\n \"timeoutSecondsHint\": \"サーキットブレーカーを開いた後、復旧を試みるまでの時間(半開状態)\",\n \"successThresholdHint\": \"半開状態で成功してサーキットブレーカーを閉じる回数\",\n \"errorRateThresholdHint\": \"エラー率がこの値を超えるとサーキットブレーカーを開く\",\n \"minRequestsHint\": \"エラー率を計算する前の最小リクエスト数\",\n \"saveConfig\": \"設定を保存\",\n \"instructionsTitle\": \"設定説明\",\n \"instructions\": {\n \"failureThreshold\": \"連続失敗がこの回数に達するとサーキットブレーカーが開く\",\n \"timeout\": \"サーキットブレーカーを開いた後、この時間待機してから半開を試みる\",\n \"successThreshold\": \"半開状態で成功がこの回数に達するとサーキットブレーカーを閉じる\",\n \"errorRate\": \"エラー率がこの値を超えるとサーキットブレーカーが開く\",\n \"minRequests\": \"リクエスト数がこの値に達した後にのみエラー率が計算される\"\n }\n },\n \"universalProvider\": {\n \"duplicate\": \"複製\",\n \"duplicatedAndSynced\": \"統合プロバイダーを複製して同期しました\",\n \"duplicateError\": \"統合プロバイダーの複製に失敗しました\",\n \"title\": \"統合プロバイダー\",\n \"description\": \"統合プロバイダーは Claude、Codex、Gemini の設定を同時に管理します。変更は有効なすべてのアプリに自動的に同期されます。\",\n \"add\": \"統合プロバイダーを追加\",\n \"edit\": \"統合プロバイダーを編集\",\n \"empty\": \"統合プロバイダーがありません\",\n \"emptyHint\": \"下の「統合プロバイダーを追加」ボタンをクリックして作成してください\",\n \"selectPreset\": \"プリセットタイプを選択\",\n \"name\": \"名前\",\n \"namePlaceholder\": \"例:私の NewAPI\",\n \"baseUrl\": \"API URL\",\n \"apiKey\": \"API キー\",\n \"websiteUrl\": \"ウェブサイト URL\",\n \"websiteUrlPlaceholder\": \"https://example.com(オプション、リストに表示されます)\",\n \"notes\": \"メモ\",\n \"notesPlaceholder\": \"オプション:メモを追加\",\n \"enabledApps\": \"有効なアプリ\",\n \"modelConfig\": \"モデル設定\",\n \"model\": \"モデル\",\n \"sync\": \"アプリに同期\",\n \"synced\": \"すべてのアプリに同期しました\",\n \"syncError\": \"同期に失敗しました\",\n \"noAppsEnabled\": \"有効なアプリがありません\",\n \"added\": \"統合プロバイダーを追加しました\",\n \"addedAndSynced\": \"統合プロバイダーを追加して同期しました\",\n \"addedButSyncFailed\": \"統合プロバイダーを追加しましたが、同期に失敗しました\",\n \"updated\": \"統合プロバイダーを更新しました\",\n \"deleted\": \"統合プロバイダーを削除しました\",\n \"addFailed\": \"統合プロバイダーの追加に失敗しました\",\n \"hint\": \"クロスアプリ統合設定。Claude/Codex/Gemini に自動同期します\",\n \"manage\": \"管理\",\n \"loadError\": \"統合プロバイダーの読み込みに失敗しました\",\n \"saveError\": \"統合プロバイダーの保存に失敗しました\",\n \"deleteError\": \"統合プロバイダーの削除に失敗しました\",\n \"deleteConfirmTitle\": \"統合プロバイダーを削除\",\n \"deleteConfirmDescription\": \"「{{name}}」を削除してもよろしいですか?各アプリで生成されたプロバイダー設定も削除されます。\",\n \"syncConfirmTitle\": \"統合プロバイダーを同期\",\n \"syncConfirmDescription\": \"「{{name}}」を同期すると、Claude、Codex、Gemini の関連プロバイダー設定が上書きされます。続行しますか?\",\n \"syncConfirm\": \"同期\",\n \"saveAndSync\": \"保存して同期\",\n \"savedAndSynced\": \"すべてのアプリに保存・同期されました\",\n \"saveAndSyncError\": \"保存と同期に失敗しました\",\n \"configJsonPreview\": \"設定 JSON プレビュー\",\n \"configJsonPreviewHint\": \"以下は各アプリに同期される設定内容です(表示されているフィールドのみ上書きされ、他のカスタム設定は保持されます)\"\n },\n \"omo\": {\n \"editProfile\": \"OMO 設定を編集\",\n \"newProfile\": \"新規 OMO 設定\",\n \"profileName\": \"名前\",\n \"mainAgents\": \"メインエージェント\",\n \"subAgents\": \"サブエージェント\",\n \"categories\": \"カテゴリ\",\n \"customAgents\": \"カスタムエージェント\",\n \"noCustomAgents\": \"カスタムエージェントなし\",\n \"otherFields\": \"その他の設定\",\n \"globalConfig\": \"OMO グローバル設定\",\n \"globalConfigShort\": \"OMO 設定\",\n \"globalConfigSaved\": \"グローバル設定を保存しました\",\n \"addProfile\": \"OMO プロバイダーを追加\",\n \"disabledItems\": \"無効項目設定\",\n \"advanced\": \"詳細設定\",\n \"profileCreated\": \"OMO 設定を作成しました\",\n \"profileUpdated\": \"OMO 設定を更新しました\",\n \"invalidJson\": \"その他のフィールドに無効なJSONが含まれています\",\n \"confirmDelete\": \"設定を削除\",\n \"confirmDeleteMsg\": \"「{{name}}」を削除しますか?\",\n \"profileDeleted\": \"設定を削除しました\",\n \"imported\": \"「{{name}}」としてインポートしました\",\n \"import\": \"インポート\",\n \"global\": \"グローバル\",\n \"empty\": \"OMO 設定がありません。+ 追加またはローカルからインポートしてください。\",\n \"applied\": \"適用済み\",\n \"apply\": \"適用\",\n \"enable\": \"有効化\",\n \"enabled\": \"有効中\",\n \"disabled\": \"OMO を無効化しました\",\n \"disableFailed\": \"OMO の無効化に失敗しました: {{error}}\",\n \"selectPlaceholder\": \"選択してください...\",\n \"clear\": \"クリア\",\n \"clearWrapped\": \"(クリア)\",\n \"defaultWrapped\": \"(デフォルト)\",\n \"variantPlaceholder\": \"variant\",\n \"selectEnabledModel\": \"設定済みモデルを選択\",\n \"selectModel\": \"設定済みモデルを選択\",\n \"recommendedHint\": \"推奨: {{model}}\",\n \"searchModel\": \"モデルを検索...\",\n \"selectModelFirst\": \"先にモデルを選択\",\n \"noEnabledModels\": \"設定済みモデルがありません\",\n \"noVariantsForModel\": \"このモデルには思考レベルがありません\",\n \"currentValueNotEnabled\": \"{{value}} (現在値・未設定)\",\n \"currentValueUnavailable\": \"{{value}} (現在値・利用不可)\",\n \"advancedLabel\": \"詳細\",\n \"advancedJsonInvalid\": \"詳細 JSON が不正です\",\n \"advancedJsonHint\": \"temperature, top_p, budgetTokens, prompt_append, permission など。空欄でデフォルトを使用します\",\n \"noEnabledModelsWarning\": \"利用可能な設定済みモデルがありません。先に OpenCode モデルを設定してください。\",\n \"modelSourcePartialWarning\": \"一部プロバイダーのモデル設定が不正なため、候補から除外しました。\",\n \"modelSourceFallbackWarning\": \"live プロバイダー状態の取得に失敗したため、設定済みプロバイダーへフォールバックしました。\",\n \"importLocalReplaceSuccess\": \"ローカルファイルから読み込み、Agents/Categories/Other Fields を置き換えました\",\n \"importLocalFailed\": \"ローカルファイルの読み込みに失敗しました: {{error}}\",\n \"agentKeyPlaceholder\": \"agent キー\",\n \"categoryKeyPlaceholder\": \"カテゴリキー\",\n \"modelNamePlaceholder\": \"model-name\",\n \"custom\": \"カスタム\",\n \"customCategories\": \"カスタムカテゴリ\",\n \"modelConfiguration\": \"モデル設定\",\n \"fillRecommended\": \"推奨を入力\",\n \"fillRecommendedSuccess\": \"推奨モデル {{count}} 件を設定しました\",\n \"fillRecommendedPartial\": \"推奨モデル {{filled}} 件を設定、{{unmatched}} 件は未一致\",\n \"fillRecommendedMostlyUnmatched\": \"{{filled}} 件のみ設定、{{unmatched}} 件は未一致(例: {{examples}})。これらのモデルを提供するプロバイダーを設定するか、代替を選択してください。\",\n \"fillRecommendedAllSet\": \"すべてのスロットにモデルが設定済みです\",\n \"fillRecommendedNoMatch\": \"推奨モデルが設定済みプロバイダーに見つかりませんでした(例: {{examples}})\",\n \"configSummary\": \"{{agents}} 個の Agent、{{categories}} 個の Category を設定済み · ⚙ で詳細を展開\",\n \"enabledModelsCount\": \"設定済みモデル {{count}} 件\",\n \"source\": \"出典:\",\n \"otherFieldsJson\": \"その他のフィールド (JSON)\",\n \"slimOtherFieldsHint\": \"council、fallback、multiplexer、disabled_mcps、todoContinuation など、OMO Slim のトップレベル設定はここに記述します。\",\n \"searchOrType\": \"検索またはカスタム値を入力...\",\n \"noMatches\": \"一致する項目がありません\",\n \"jsonMustBeObject\": \"{{field}} は JSON オブジェクトである必要があります\",\n \"jsonInvalid\": \"{{field}} に無効な JSON が含まれています\",\n \"importGlobalSuccess\": \"ローカルファイルからグローバル設定を読み込みました(未保存)\",\n \"importGlobalFailed\": \"ローカルファイルの読み込みに失敗しました: {{error}}\",\n \"importLocal\": \"ローカルからインポート\",\n \"saveGlobalConfig\": \"グローバル設定を保存\",\n \"schemaUrl\": \"$schema\",\n \"resetDefault\": \"デフォルトに戻す\",\n \"sisyphusAgentConfig\": \"Sisyphus Agent 設定\",\n \"disabledAgents\": \"Agents\",\n \"disabledAgentsPlaceholder\": \"無効化する Agents\",\n \"disabledMcps\": \"MCPs\",\n \"disabledMcpsPlaceholder\": \"無効化する MCPs\",\n \"disabledHooks\": \"Hooks\",\n \"disabledHooksPlaceholder\": \"無効化する Hooks\",\n \"disabledSkills\": \"Skills\",\n \"disabledSkillsPlaceholder\": \"無効化する Skills\",\n \"advancedLsp\": \"LSP 設定\",\n \"advancedExperimental\": \"実験的機能\",\n \"advancedBackgroundTask\": \"バックグラウンドタスク\",\n \"advancedBrowserAutomation\": \"ブラウザ自動化\",\n \"advancedClaudeCode\": \"Claude Code\",\n \"agentDesc\": {\n \"sisyphus\": \"メインオーケストレーター\",\n \"hephaestus\": \"自律型ディープワーカー\",\n \"prometheus\": \"戦略プランナー\",\n \"atlas\": \"タスクマネージャー\",\n \"oracle\": \"戦略アドバイザー\",\n \"librarian\": \"マルチリポジトリ研究員\",\n \"explore\": \"高速コード検索\",\n \"multimodalLooker\": \"メディアアナライザー\",\n \"metis\": \"計画前分析アドバイザー\",\n \"momus\": \"プランレビュアー\",\n \"sisyphusJunior\": \"委任タスクエグゼキューター\"\n },\n \"agentTooltip\": {\n \"sisyphus\": \"メインオーケストレーター。タスクの計画、委任、並列実行を担当。拡張思考(32k バジェット)を使用し、TODO リストでワークフローを駆動してタスク完了を確保します。\",\n \"atlas\": \"メインオーケストレーター(TODO リスト保持)。実行フェーズでのタスク配分と調整を担当。すべての作業を直接行うのではなく、専門エージェントに委任します。\",\n \"prometheus\": \"戦略プランナー。インタビューモードで要件を収集し、詳細な作業計画を策定。.sisyphus/ ディレクトリ内の Markdown ファイルの読み書きのみ可能で、直接コードを書くことはありません。\",\n \"hephaestus\": \"自律型ディープワーカー(「正当な職人」)。AmpCode ディープモードに着想を得た目標指向の実行を行い、行動前に 2-5 個の探索/ライブラリアンエージェントを並列起動して調査します。\",\n \"oracle\": \"アーキテクチャ決定とデバッグのアドバイザー。読み取り専用のコンサルティングエージェントで、優れた論理的推論と深い分析を提供。ファイルの書き込みやタスクの委任はできません。\",\n \"librarian\": \"マルチリポジトリ分析とドキュメント検索の専門家。コードベースを深く理解し、エビデンスに基づく回答を提供。公式ドキュメントやオープンソース実装例の検索に長けています。\",\n \"explore\": \"高速コードベース探索とコンテキスト grep の専門家。軽量モデルを使用した高速検索で、コード構造を理解するための先鋒です。\",\n \"multimodalLooker\": \"ビジュアルコンテンツの専門家。PDF、画像、グラフなどの非テキストメディアを分析し、情報とインサイトを抽出します。\",\n \"metis\": \"プランコンサルタント。計画前に事前分析を行い、隠れた意図、曖昧な点、AI の失敗ポイントを特定して、過剰エンジニアリングを防止します。\",\n \"momus\": \"プランレビュアー。計画の明確さ、検証可能性、完全性を高精度で検証。計画が完璧になるまで却下と修正を要求します。\",\n \"sisyphusJunior\": \"カテゴリ生成のエグゼキューター。category パラメータにより自動生成され、割り当てられたタスクの実行に専念し、再委任はできません。無限委任ループを防止します。\"\n },\n \"categoryDesc\": {\n \"visualEngineering\": \"ビジュアル/フロントエンド工学\",\n \"ultrabrain\": \"超深度思考\",\n \"deep\": \"ディープワーク\",\n \"artistry\": \"クリエイティブ/芸術\",\n \"quick\": \"クイックレスポンス\",\n \"unspecifiedLow\": \"汎用ロースペック\",\n \"unspecifiedHigh\": \"汎用ハイスペック\",\n \"writing\": \"ライティング\"\n },\n \"categoryTooltip\": {\n \"visualEngineering\": \"フロントエンドとビジュアルエンジニアリングカテゴリ。UI/UX デザイン、スタイリング、アニメーション、インターフェース実装に特化。デフォルトで Gemini 3.5 Flash モデルを使用。\",\n \"ultrabrain\": \"ディープロジック推論カテゴリ。広範な分析が必要な複雑なアーキテクチャ決定に使用。デフォルトで GPT-5.3 Codex の超高推論バリアントを使用。\",\n \"deep\": \"ディープ自律問題解決カテゴリ。目標指向の実行で、行動前に徹底的な調査を実施。深い理解が必要な難問に適しています。\",\n \"artistry\": \"高度にクリエイティブで芸術的なタスクカテゴリ。斬新なアイデアとクリエイティブなソリューションを促進。デフォルトで Gemini 3.5 Flash の高推論バリアントを使用。\",\n \"quick\": \"軽量タスクカテゴリ。単一ファイルの修正、タイポ修正、簡単な調整などの些細な作業に使用。デフォルトで Claude Haiku 4.5 高速モデルを使用。\",\n \"unspecifiedLow\": \"未分類の低作業量タスクカテゴリ。他のカテゴリに該当せず作業量が小さいタスクに適用。デフォルトで Claude Sonnet 4.5 を使用。\",\n \"unspecifiedHigh\": \"未分類の高作業量タスクカテゴリ。他のカテゴリに該当せず作業量が大きいタスクに適用。デフォルトで Claude Opus 4.8 の max バリアントを使用。\",\n \"writing\": \"ライティングカテゴリ。ドキュメント、散文、技術文書に特化。デフォルトで Kimi K2.5 モデルを使用。\"\n },\n \"slimAgentDesc\": {\n \"orchestrator\": \"オーケストレーター\",\n \"oracle\": \"オラクル\",\n \"librarian\": \"ライブラリアン\",\n \"explorer\": \"エクスプローラー\",\n \"designer\": \"デザイナー\",\n \"fixer\": \"フィクサー\",\n \"council\": \"カウンシル\"\n },\n \"slimAgentTooltip\": {\n \"orchestrator\": \"実行コードの作成、マルチエージェントワークフローの調整、エキスパートの召喚\",\n \"oracle\": \"根本原因分析、アーキテクチャレビュー、デバッグガイダンス(読み取り専用)\",\n \"librarian\": \"ドキュメント検索、GitHubコード検索(読み取り専用)\",\n \"explorer\": \"正規表現検索、ASTパターンマッチング、ファイル検出(読み取り専用)\",\n \"designer\": \"モダンなレスポンシブデザイン、CSS/Tailwindの専門知識\",\n \"fixer\": \"コード実装、リファクタリング、テスト、検証\",\n \"council\": \"複数モデルの合議エージェント。複数の読み取り専用 councillor を並列実行し、master が最終回答を統合します。\"\n }\n },\n \"openclawConfig\": {\n \"defaultModel\": {\n \"title\": \"デフォルトモデル\",\n \"description\": \"OpenClaw のデフォルトのプライマリモデルとフォールバックモデルを設定します\",\n \"primary\": \"プライマリモデル\",\n \"primaryPlaceholder\": \"例: deepseek/deepseek-chat\",\n \"fallbacks\": \"フォールバックモデル\",\n \"fallbacksPlaceholder\": \"例: openrouter/anthropic/claude-sonnet-4.5\",\n \"addFallback\": \"フォールバックモデルを追加\",\n \"saved\": \"デフォルトモデル設定を保存しました\",\n \"saveFailed\": \"デフォルトモデルの保存に失敗しました\"\n },\n \"modelCatalog\": {\n \"title\": \"モデルカタログ\",\n \"description\": \"モデルのエイリアスと許可リストを設定します\",\n \"modelId\": \"モデル ID\",\n \"modelIdPlaceholder\": \"例: deepseek/deepseek-chat\",\n \"alias\": \"エイリアス\",\n \"aliasPlaceholder\": \"例: DeepSeek\",\n \"addEntry\": \"モデルを追加\",\n \"removeEntry\": \"削除\",\n \"saved\": \"モデルカタログを保存しました\",\n \"saveFailed\": \"モデルカタログの保存に失敗しました\",\n \"empty\": \"モデルカタログエントリがありません\",\n \"emptyHint\": \"エイリアスを設定するにはカタログにモデルを追加してください\"\n },\n \"suggestedDefaults\": \"推奨デフォルト設定を適用\",\n \"suggestedDefaultsHint\": \"このプリセットの推奨デフォルトモデル設定を使用します\"\n },\n \"subscription\": {\n \"title\": \"サブスクリプションクォータ\",\n \"fiveHour\": \"5時間\",\n \"sevenDay\": \"7日間\",\n \"sevenDayOpus\": \"7日間(Opus)\",\n \"sevenDaySonnet\": \"7日間(Sonnet)\",\n \"thirtyDay\": \"30日間\",\n \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"週間\",\n \"monthly\": \"月間\",\n \"copilotPremium\": \"プレミアム\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}}後にリセット\",\n \"extraUsage\": \"超過使用量\",\n \"expired\": \"セッション期限切れ\",\n \"expiredHint\": \"{{tool}}コマンドを実行してログインを更新してください\",\n \"queryFailed\": \"クエリに失敗しました\",\n \"refresh\": \"更新\"\n }\n}\n"}}, "src/i18n/locales/zh.json": {"1e398a3709f03db3fe5d989827090386b9dcd3be": {"t": 1785674687.220469, "n": 109875, "h": "1ec6cb7e840eee0d", "c": "{\n \"app\": {\n \"title\": \"CC Switch\",\n \"description\": \"Claude Code / Codex / Gemini CLI 全方位辅助工具\"\n },\n \"common\": {\n \"add\": \"添加\",\n \"edit\": \"编辑\",\n \"delete\": \"删除\",\n \"save\": \"保存\",\n \"saving\": \"保存中...\",\n \"cancel\": \"取消\",\n \"confirm\": \"确定\",\n \"close\": \"关闭\",\n \"done\": \"完成\",\n \"settings\": \"设置\",\n \"about\": \"关于\",\n \"version\": \"版本\",\n \"loading\": \"加载中...\",\n \"notInstalled\": \"未安装\",\n \"success\": \"成功\",\n \"error\": \"错误\",\n \"unknown\": \"未知\",\n \"enterValidValue\": \"请输入有效的内容\",\n \"clear\": \"清除\",\n \"toggleTheme\": \"切换主题\",\n \"format\": \"格式化\",\n \"formatSuccess\": \"格式化成功\",\n \"formatError\": \"格式化失败:{{error}}\",\n \"copy\": \"复制\",\n \"view\": \"查看\",\n \"back\": \"返回\",\n \"refresh\": \"刷新\",\n \"refreshing\": \"刷新中...\",\n \"import\": \"导入\",\n \"all\": \"全部\",\n \"search\": \"查询\",\n \"reset\": \"重置\",\n \"actions\": \"操作\",\n \"deleting\": \"删除中...\",\n \"auto\": \"自动\",\n \"enabled\": \"已开启\",\n \"notSet\": \"未设置\"\n },\n \"firstRunNotice\": {\n \"title\": \"欢迎使用 CC Switch\",\n \"bodyDefault\": \"CC Switch 可以帮你在 Claude Code / Codex / Gemini CLI 的多个供应商之间一键切换。如果你之前已经配置过这些工具,CC Switch 会自动把现有设置保存为名为 “default” 的供应商,保证你的配置不会丢失。\",\n \"bodyOfficial\": \"列表里还预置了 “官方(Official)” 供应商,随时点一下即可切回官方默认配置。切换前 CC Switch 会自动把当前配置备份回 default,可以放心来回切。CC Switch 就是这样工作的 😊\",\n \"confirm\": \"我知道了\"\n },\n \"apiKeyInput\": {\n \"placeholder\": \"请输入API Key\",\n \"show\": \"显示API Key\",\n \"hide\": \"隐藏API Key\"\n },\n \"jsonEditor\": {\n \"mustBeObject\": \"配置必须是JSON对象,不能是数组或其他类型\",\n \"invalidJson\": \"JSON格式错误\"\n },\n \"commonConfig\": {\n \"guideTitle\": \"什么是通用配置片段?\",\n \"guidePurpose\": \"用来在不同供应商之间共享插件、环境变量等非敏感配置。切换供应商时不会丢失这些设置。\",\n \"guideUsage\": \"用法:① 点击「从编辑内容提取」保存通用部分 ② 新建供应商时勾选「应用通用配置」\",\n \"guideReExtract\": \"如果您新安装了插件或 Hook,请重新提取一次通用配置,以便同步到其他供应商。\",\n \"guideReassurance\": \"放心:您的原始配置已保存在默认供应商中,不会丢失。\",\n \"emptyTitle\": \"还没有通用配置片段\",\n \"emptyHint\": \"点击下方「从编辑内容提取」按钮,从当前配置中提取可复用的部分\"\n },\n \"claudeConfig\": {\n \"configLabel\": \"Claude Code 配置 (JSON) *\",\n \"writeCommonConfig\": \"应用通用配置\",\n \"editCommonConfig\": \"编辑通用配置\",\n \"editCommonConfigTitle\": \"编辑通用配置片段\",\n \"commonConfigHint\": \"该片段会在勾选\\\"应用通用配置\\\"时合并到 settings.json 中\",\n \"fullSettingsHint\": \"完整的 Claude Code settings.json 配置内容\",\n \"extractFromCurrent\": \"从编辑内容提取\",\n \"extractNoCommonConfig\": \"当前编辑内容没有可提取的通用配置\",\n \"extractFailed\": \"提取失败: {{error}}\",\n \"saveFailed\": \"保存失败: {{error}}\",\n \"hideAttribution\": \"隐藏 AI 署名\",\n \"enableTeammates\": \"Teammates 模式\",\n \"enableToolSearch\": \"启用 Tool Search\",\n \"effortMax\": \"最大强度思考\",\n \"disableAutoUpgrade\": \"禁用自动升级\"\n },\n \"header\": {\n \"viewOnGithub\": \"在 GitHub 上查看\",\n \"toggleDarkMode\": \"切换到暗色模式\",\n \"toggleLightMode\": \"切换到亮色模式\",\n \"addProvider\": \"添加供应商\",\n \"switchToChinese\": \"切换到中文\",\n \"switchToEnglish\": \"切换到英文\",\n \"enterEditMode\": \"进入编辑模式\",\n \"exitEditMode\": \"退出编辑模式\",\n \"windowMinimize\": \"最小化窗口\",\n \"windowMaximize\": \"最大化窗口\",\n \"windowRestore\": \"还原窗口\",\n \"windowClose\": \"关闭窗口\"\n },\n \"provider\": {\n \"connectivityCheck\": \"检测连通\",\n \"tabProvider\": \"供应商\",\n \"tabUniversal\": \"统一供应商\",\n \"noProviders\": \"还没有添加任何供应商\",\n \"noProvidersDescription\": \"如果你已有配置,请点击\\\"导入当前配置\\\",所有数据将安全保存在 default 供应商中\",\n \"noProvidersDescriptionSnippet\": \"除 Key 和请求地址外的数据(如插件配置)会被保存到通用配置片段,用于在不同供应商之间共享\",\n \"importCurrent\": \"导入当前配置\",\n \"importFromClaude\": \"将 Claude Code 中已有的供应商导入\",\n \"importCurrentDescription\": \"将当前正在使用的配置导入为默认供应商\",\n \"currentlyUsing\": \"当前使用\",\n \"enable\": \"启用\",\n \"inUse\": \"使用中\",\n \"blockedByProxyHint\": \"代理接管模式下不可切换到官方供应商\",\n \"editProvider\": \"编辑供应商\",\n \"editProviderHint\": \"更新配置后将立即应用到当前供应商。\",\n \"deleteProvider\": \"删除供应商\",\n \"addNewProvider\": \"添加新供应商\",\n \"addFooterHint\": \"💡 选择预设后,请在下方填写 API Key 等字段\",\n \"addClaudeProvider\": \"添加 Claude Code 供应商\",\n \"addCodexProvider\": \"添加 Codex 供应商\",\n \"addGeminiProvider\": \"添加 Gemini 供应商\",\n \"addOpenCodeProvider\": \"添加 OpenCode 供应商\",\n \"addToConfig\": \"添加\",\n \"removeFromConfig\": \"移除\",\n \"setAsDefault\": \"设为默认\",\n \"isDefault\": \"当前默认\",\n \"inConfig\": \"已添加\",\n \"addProviderHint\": \"填写信息后即可在列表中快速切换供应商。\",\n \"editClaudeProvider\": \"编辑 Claude Code 供应商\",\n \"editCodexProvider\": \"编辑 Codex 供应商\",\n \"configError\": \"配置错误\",\n \"notConfigured\": \"未配置官网地址\",\n \"applyToClaudePlugin\": \"应用到 Claude 插件\",\n \"removeFromClaudePlugin\": \"从 Claude 插件移除\",\n \"dragToReorder\": \"拖拽以重新排序\",\n \"dragHandle\": \"拖拽排序\",\n \"searchPlaceholder\": \"按名称/备注/网址搜索供应商...\",\n \"searchAriaLabel\": \"搜索供应商\",\n \"searchScopeHint\": \"根据名称、备注和官网链接匹配结果。\",\n \"searchCloseHint\": \"按 Esc 关闭\",\n \"searchCloseAriaLabel\": \"关闭供应商搜索\",\n \"noSearchResults\": \"没有符合搜索条件的供应商。\",\n \"duplicate\": \"复制\",\n \"sortUpdateFailed\": \"排序更新失败\",\n \"configureUsage\": \"配置用量查询\",\n \"officialPartner\": \"官方合作伙伴\",\n \"managedByHermes\": \"Hermes 托管\",\n \"managedByHermesHint\": \"该条目定义在 Hermes 的 providers: dict,请在 Hermes Web UI 中编辑或删除。\",\n \"openTerminal\": \"打开终端\",\n \"terminalOpened\": \"终端已打开\",\n \"terminalOpenFailed\": \"打开终端失败\",\n \"name\": \"供应商名称\",\n \"namePlaceholder\": \"例如:Claude 官方\",\n \"websiteUrl\": \"官网链接\",\n \"notes\": \"备注\",\n \"notesPlaceholder\": \"例如:公司专用账号\",\n \"configJson\": \"配置 JSON\",\n \"writeCommonConfig\": \"应用通用配置\",\n \"editCommonConfigButton\": \"编辑通用配置\",\n \"configJsonHint\": \"请填写完整的 Claude Code 配置\",\n \"editCommonConfigTitle\": \"编辑通用配置片段\",\n \"editCommonConfigHint\": \"通用配置片段将合并到所有启用它的供应商配置中\",\n \"addProvider\": \"添加供应商\",\n \"sortUpdated\": \"排序已更新\",\n \"usageSaved\": \"用量查询配置已保存\",\n \"usageSaveFailed\": \"用量查询配置保存失败\",\n \"geminiConfig\": \"Gemini 配置\",\n \"geminiConfigHint\": \"使用 .env 格式配置 Gemini\",\n \"form\": {\n \"gemini\": {\n \"model\": \"模型\",\n \"oauthTitle\": \"OAuth 认证模式\",\n \"oauthHint\": \"Google 官方使用 OAuth 个人认证,无需填写 API Key。首次使用时会自动打开浏览器进行登录。\",\n \"apiKeyPlaceholder\": \"请输入 Gemini API Key\"\n }\n }\n },\n \"claudeCode\": {\n \"needsRouting\": \"需要路由\",\n \"noRoutingSupport\": \"不支持路由\"\n },\n \"codex\": {\n \"needsRouting\": \"需要路由\",\n \"noRoutingSupport\": \"不支持路由\",\n \"officialRouting\": \"官方账号路由\",\n \"nativeLogin\": \"Codex 登录\"\n },\n \"claudeDesktop\": {\n \"officialNotice\": \"Claude Desktop 官方供应商使用应用内置的 1P 登录,无需配置 API Key 和接口地址。\",\n \"mode\": \"模型处理方式\",\n \"modeDirect\": \"直连\",\n \"modeProxy\": \"需要路由\",\n \"modelMappingToggle\": \"需要模型映射\",\n \"modelMappingOffHint\": \"仅当供应商直接接受 Claude Desktop 可识别的三档角色 ID(claude-sonnet-* / claude-opus-* / claude-haiku-*)时才适用直连;其他模型名(含 claude-3-5-sonnet-… 等旧式 ID)请打开此开关走映射。\",\n \"modelMappingOnHint\": \"Claude Desktop 只接受 claude-sonnet-* / claude-opus-* / claude-haiku-* 三档角色 ID。开启后 CC Switch 会把这三档映射到供应商的实际模型,并在使用期间保持本地路由开启。\",\n \"routeMapTitle\": \"模型映射\",\n \"routeMapHint\": \"为 Sonnet、Opus、Haiku 三档分别填写实际请求模型;菜单显示名可写 DeepSeek、Kimi 等品牌名。留空的档会自动沿用 Sonnet(或第一个已填档)的模型,确保子 agent 调用的 Haiku 始终可用。\",\n \"routeModelLabel\": \"模型角色\",\n \"routeRoleSonnet\": \"Sonnet\",\n \"routeRoleOpus\": \"Opus\",\n \"routeRoleFable\": \"Fable\",\n \"routeRoleHaiku\": \"Haiku\",\n \"labelOverrideLabel\": \"菜单显示名\",\n \"upstreamModelLabel\": \"实际请求模型\",\n \"supports1mLabel\": \"声明支持 1M\",\n \"supports1mShort\": \"1M\",\n \"directModelListTitle\": \"手动指定 Claude Desktop 模型列表(高级,可选)\",\n \"directModelListCollapsedHint\": \"原生 Claude 模型供应商通常不用填写,Claude Desktop 会自动读取 /v1/models。\",\n \"directModelListHint\": \"仅当供应商的 /v1/models 不可用或没有返回 Claude Desktop 可识别的 Sonnet / Opus / Haiku 模型名时填写;勾选 1M 会向 Claude Desktop 声明支持 1M 上下文。\",\n \"directModelInvalid\": \"直连模型必须使用 Claude Desktop 可识别的 Sonnet / Opus / Haiku 模型名\",\n \"addModel\": \"添加模型\",\n \"addRoute\": \"添加模型\",\n \"routeInvalid\": \"请选择模型角色并填写实际请求模型\",\n \"routesRequired\": \"至少填写一个模型映射\",\n \"statusTitle\": \"Claude Desktop 配置需要检查\",\n \"statusUnsupported\": \"当前平台暂不支持 Claude Desktop 3P 配置写入。\",\n \"statusStaleRawModels\": \"Claude Desktop profile 中存在非 Sonnet / Opus / Haiku 角色模型名,新版 Claude Desktop 可能拒绝加载;重新切换当前供应商可修复。\",\n \"statusMissingRouteMappings\": \"当前供应商启用了模型映射,但没有有效路由;请编辑供应商并补全至少一个模型映射。\",\n \"statusGatewayTokenMissing\": \"当前本地路由 token 尚未生成;重新切换该供应商会写入新的本地 token。\",\n \"statusBaseUrlMismatch\": \"Claude Desktop profile 指向的地址与当前供应商不一致;当前为 {{actual}},应为 {{expected}}。重新切换当前供应商可修复。\",\n \"route\": {\n \"tooltip\": {\n \"active\": \"Claude Desktop 本地路由已开启 - {{address}}:{{port}}\",\n \"inactive\": \"开启 Claude Desktop 本地路由,用于需要模型映射或格式转换的供应商。当前配置地址:{{address}}:{{port}}\"\n }\n }\n },\n \"notifications\": {\n \"providerAdded\": \"供应商已添加\",\n \"providerSaved\": \"供应商配置已保存\",\n \"providerDeleted\": \"供应商删除成功\",\n \"switchSuccess\": \"切换成功!\",\n \"codexRestartRequired\": \"切换成功,请重启客户端以生效\",\n \"claudeDesktopRestartRequired\": \"切换成功,重启 Claude Desktop 后生效\",\n \"claudeDesktopProxyRestartRequired\": \"切换成功,请保持 CC Switch 运行,并重启 Claude Desktop 后生效\",\n \"addToConfigSuccess\": \"已添加到配置\",\n \"removeFromConfigSuccess\": \"已从配置移除\",\n \"switchFailedTitle\": \"切换失败\",\n \"switchFailed\": \"切换失败:{{error}}\",\n \"autoImported\": \"已从现有配置创建默认供应商\",\n \"addFailed\": \"添加供应商失败:{{error}}\",\n \"saveFailed\": \"保存失败:{{error}}\",\n \"saveFailedGeneric\": \"保存失败,请重试\",\n \"appliedToClaudePlugin\": \"已应用到 Claude 插件\",\n \"removedFromClaudePlugin\": \"已从 Claude 插件移除\",\n \"syncClaudePluginFailed\": \"同步 Claude 插件失败\",\n \"skipClaudeOnboardingFailed\": \"跳过 Claude Code 初次安装确认失败\",\n \"clearClaudeOnboardingSkipFailed\": \"恢复 Claude Code 初次安装确认失败\",\n \"updateSuccess\": \"供应商更新成功\",\n \"updateFailed\": \"更新供应商失败:{{error}}\",\n \"deleteSuccess\": \"供应商已删除\",\n \"deleteFailed\": \"删除供应商失败:{{error}}\",\n \"settingsSaved\": \"设置已保存\",\n \"settingsSaveFailed\": \"保存设置失败:{{error}}\",\n \"proxyRequiredForSwitch\": \"此供应商{{reason}},需要路由服务才能正常使用,请先启动路由\",\n \"proxyReasonCopilot\": \"使用 GitHub Copilot 作为 Claude 供应商\",\n \"proxyReasonOpenAIChat\": \"使用 OpenAI Chat 接口格式\",\n \"proxyReasonOpenAIResponses\": \"使用 OpenAI Responses 接口格式\",\n \"proxyReasonFullUrl\": \"开启了完整 URL 连接模式\",\n \"openAIFormatHint\": \"此供应商使用 OpenAI 兼容格式,需要开启路由服务才能正常使用\",\n \"copilotProxyHint\": \"GitHub Copilot 作为 Claude 供应商时始终需要本地路由;路由会根据当前模型自动选择 Chat Completions 或 Responses。\",\n \"openLinkFailed\": \"链接打开失败\",\n \"openclawModelsRegistered\": \"模型已注册到 /model 列表\",\n \"openclawDefaultModelSet\": \"已设为默认模型\",\n \"openclawDefaultModelSetFailed\": \"设置默认模型失败\",\n \"openclawNoModels\": \"该供应商没有配置模型\",\n \"backfillWarning\": \"切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存\",\n \"windowControlFailed\": \"窗口控制失败:{{error}}\",\n \"officialBlockedByProxy\": \"本地路由模式下不能切换到官方供应商,使用路由访问官方 API 可能导致账号被封禁\",\n \"proxyOfficialWarning\": \"当前供应商 {{name}} 是官方供应商,建议切换到第三方供应商后再使用本地路由\"\n },\n \"confirm\": {\n \"deleteProvider\": \"删除供应商\",\n \"deleteProviderMessage\": \"确定要删除供应商 \\\"{{name}}\\\" 吗?此操作无法撤销。\",\n \"removeProvider\": \"移除供应商\",\n \"removeProviderMessage\": \"确定要从配置中移除供应商 \\\"{{name}}\\\" 吗?\\n\\n移除后该供应商将不再生效,但配置数据会保留在 CC Switch 中,您可以随时重新添加。\",\n \"proxy\": {\n \"title\": \"启用本地路由服务\",\n \"message\": \"本地路由是一项高级功能,启用前请确保您已了解其工作原理。\\n\\n建议先查阅相关文档或咨询您的供应商,以获取正确的配置方式。\",\n \"confirm\": \"我已了解,继续启用\"\n },\n \"failover\": {\n \"title\": \"启用故障转移功能\",\n \"message\": \"故障转移是一项高级功能,启用前请确保您已了解其工作原理。\\n\\n建议先在故障转移队列中配置好供应商优先级。\",\n \"confirm\": \"我已了解,继续启用\"\n },\n \"unifyCodexHistory\": {\n \"title\": \"统一 Codex 会话历史\",\n \"message\": \"开启后,官方订阅与第三方将共用同一个会话历史列表。注意:跨供应商继续旧会话时,可能因对方后端无法解密 encrypted_content 推理内容而失败。\\n\\n可选择同时把现有官方会话历史迁入共享列表(迁移前自动备份到 ~/.cc-switch/backups,关闭开关时可选择恢复)。\",\n \"migrateExisting\": \"同时迁入现有官方会话历史\",\n \"confirm\": \"我已了解,继续开启\"\n },\n \"unifyCodexHistoryOff\": {\n \"title\": \"关闭统一会话历史\",\n \"message\": \"关闭后,官方订阅与第三方将恢复各自独立的会话历史列表。开启期间产生的会话因无法区分来源,将留在第三方历史中,官方订阅将看不到它们。\",\n \"restoreBackup\": \"把开启时迁入的官方会话还原回官方历史(按备份精确还原)\",\n \"confirm\": \"关闭\"\n },\n \"usage\": {\n \"title\": \"配置用量查询\",\n \"message\": \"用量查询需要配置专用的查询脚本或 API 参数,请确保您已从供应商处获取相关信息。\\n\\n如不确定如何配置,请先查阅供应商文档。\",\n \"confirm\": \"我已了解,继续配置\"\n },\n \"autoSync\": {\n \"title\": \"开启自动同步\",\n \"message\": \"开启自动同步后,每次数据库变更都会自动上传到 WebDAV 服务器。\\n\\n这可能会产生较高的网络流量消耗,请确保您的网络环境和 WebDAV 服务支持频繁的数据传输。\",\n \"confirm\": \"我已了解,继续开启\"\n },\n \"commonConfig\": {\n \"title\": \"关于通用配置\",\n \"message\": \"「通用配置片段」可以在不同供应商之间共享插件、环境变量等配置,避免切换供应商时丢失这些设置。\\n\\n使用方法:\\n① 编辑供应商时点击「编辑通用配置」→「从编辑内容提取」\\n② 新建供应商时勾选「应用通用配置」(默认已勾选)\\n\\n如果您新安装了插件或 Hook,请重新提取一次通用配置。\",\n \"confirm\": \"我知道了\"\n }\n },\n \"settings\": {\n \"title\": \"设置\",\n \"general\": \"通用\",\n \"tabGeneral\": \"通用\",\n \"tabAuth\": \"认证\",\n \"tabAdvanced\": \"高级\",\n \"tabProxy\": \"路由\",\n \"authCenter\": {\n \"title\": \"OAuth 认证中心\",\n \"description\": \"在 Claude Code 中使用您的其他订阅,请注意合规风险。\",\n \"beta\": \"Beta\",\n \"copilotDescription\": \"管理 GitHub Copilot 账号\",\n \"codexOauthDescription\": \"管理 ChatGPT 账号\"\n },\n \"advanced\": {\n \"configDir\": {\n \"title\": \"配置文件目录\",\n \"description\": \"管理 Claude、Codex 和 Gemini 的配置存储路径\"\n },\n \"proxy\": {\n \"title\": \"本地路由\",\n \"description\": \"控制路由服务开关、查看状态与端口信息\",\n \"enableFeature\": \"在主页面显示本地路由开关\",\n \"enableFeatureDescription\": \"开启后,主页面顶部将显示路由和故障转移开关\",\n \"enableFailoverToggle\": \"在主页面显示故障转移开关\",\n \"enableFailoverToggleDescription\": \"开启后,主页面顶部将独立显示故障转移开关\",\n \"running\": \"运行中\",\n \"stopped\": \"已停止\"\n },\n \"connectivityCheck\": {\n \"title\": \"连通检测配置\",\n \"description\": \"配置供应商地址探测的超时、重试和较慢阈值\"\n },\n \"failover\": {\n \"title\": \"自动故障转移\",\n \"description\": \"配置故障转移队列和熔断策略\"\n },\n \"pricing\": {\n \"title\": \"成本定价\",\n \"description\": \"管理各模型 Token 计费规则\"\n },\n \"globalProxy\": {\n \"title\": \"全局出站代理\",\n \"description\": \"配置 CC Switch 访问外部 API 时使用的代理\"\n },\n \"data\": {\n \"title\": \"数据管理\",\n \"description\": \"导入和导出本地配置数据\"\n },\n \"backup\": {\n \"title\": \"备份与恢复\",\n \"description\": \"管理自动备份,查看和恢复数据库快照\"\n },\n \"cloudSync\": {\n \"title\": \"云同步\",\n \"description\": \"通过 WebDAV 在多设备间同步数据\"\n },\n \"rectifier\": {\n \"title\": \"整流器\",\n \"description\": \"自动修复 API 请求中的兼容性问题\",\n \"enabled\": \"启用整流器\",\n \"enabledDescription\": \"总开关,关闭后所有整流功能将被禁用\",\n \"requestGroup\": \"请求整流\",\n \"responseGroup\": \"响应整流\",\n \"thinkingSignature\": \"Thinking 签名整流\",\n \"thinkingSignatureDescription\": \"当 Anthropic 类型供应商返回 thinking 签名不兼容或非法请求等错误时,自动移除不兼容的 thinking 相关块并对同一供应商重试一次\",\n \"thinkingBudget\": \"Thinking Budget 整流\",\n \"thinkingBudgetDescription\": \"当 Anthropic 类型供应商返回 budget_tokens 约束错误(如至少 1024)时,自动将 thinking 规范为 enabled 并将 budget 设为 32000,同时在需要时将 max_tokens 设为 64000,然后重试一次\",\n \"mediaFallback\": \"不支持图片降级\",\n \"mediaFallbackDescription\": \"当供应商不支持图片输入时(已声明纯文本能力,或上游返回不支持图片的错误),自动将图片块替换为 [Unsupported Image] 标记,使对话不中断\",\n \"mediaHeuristic\": \"纯文本模型预判\",\n \"mediaHeuristicDescription\": \"发送前根据内置的已确认纯文本模型注册表剥离图片。关闭仅停用代理的注册表预判;已声明纯文本模型的处理和上游报错兜底仍保留,且不会改变 Codex 模型目录的能力声明\"\n },\n \"optimizer\": {\n \"title\": \"Bedrock 请求优化器\",\n \"description\": \"在请求发送前自动优化 Thinking 和 Cache 配置(仅 Bedrock 供应商生效)\",\n \"enabled\": \"启用优化器\",\n \"thinkingOptimizer\": \"Thinking 优化\",\n \"thinkingOptimizerDescription\": \"自动为 Opus/Sonnet 启用 Adaptive Thinking,为旧模型注入 Extended Thinking\",\n \"cacheInjection\": \"Cache 注入\",\n \"cacheInjectionDescription\": \"自动在请求关键位置注入标准 5 分钟 Cache 断点,减少重复 token 计费\"\n },\n \"logConfig\": {\n \"title\": \"日志管理\",\n \"description\": \"控制日志输出级别\",\n \"enabled\": \"启用日志\",\n \"enabledDescription\": \"总开关,关闭后所有日志将被禁用\",\n \"level\": \"日志级别\",\n \"levelDescription\": \"设置输出的最低日志级别\",\n \"levels\": {\n \"error\": \"错误\",\n \"warn\": \"警告\",\n \"info\": \"信息\",\n \"debug\": \"调试\",\n \"trace\": \"跟踪\"\n },\n \"levelHint\": \"日志级别说明:\",\n \"levelDesc\": {\n \"error\": \"仅严重错误\",\n \"warn\": \"错误 + 警告信息\",\n \"info\": \"一般操作信息(默认)\",\n \"debug\": \"详细信息,包含 SSE 流和请求/响应详情\",\n \"trace\": \"全部日志,最详细\"\n }\n }\n },\n \"language\": \"界面语言\",\n \"languageHint\": \"切换后立即预览界面语言,保存后永久生效。\",\n \"theme\": \"外观主题\",\n \"themeHint\": \"选择应用的外观主题,立即生效。\",\n \"themeLight\": \"浅色\",\n \"themeDark\": \"深色\",\n \"themeSystem\": \"跟随系统\",\n \"importExport\": \"SQL 导入导出\",\n \"importExportHint\": \"导入/导出数据库 SQL 备份(仅支持导入由 CC Switch 导出的备份),便于备份或迁移。\",\n \"exportConfig\": \"导出 SQL 备份\",\n \"selectConfigFile\": \"选择 SQL 文件\",\n \"noFileSelected\": \"尚未选择配置文件。\",\n \"import\": \"导入\",\n \"importing\": \"导入中...\",\n \"importSuccess\": \"导入成功!\",\n \"importFailed\": \"导入失败\",\n \"syncLiveFailed\": \"已导入,但同步到当前供应商失败,请手动重新选择一次供应商。\",\n \"importPartialSuccess\": \"配置已导入,但同步到当前供应商失败。\",\n \"importPartialHint\": \"请手动重新选择一次供应商以刷新对应配置。\",\n \"configExported\": \"配置已导出到:\",\n \"exportFailed\": \"导出失败\",\n \"selectFileFailed\": \"请选择有效的 SQL 备份文件\",\n \"configCorrupted\": \"SQL 文件可能已损坏或格式不正确\",\n \"backupId\": \"备份ID\",\n \"backupManager\": {\n \"title\": \"数据库备份\",\n \"description\": \"自动备份的数据库快照,可用于恢复到之前的状态\",\n \"empty\": \"暂无备份\",\n \"restore\": \"恢复\",\n \"restoring\": \"恢复中...\",\n \"confirmTitle\": \"确认恢复备份\",\n \"confirmMessage\": \"恢复到此备份将覆盖当前数据库。恢复前会自动创建安全备份。\",\n \"restoreSuccess\": \"恢复成功!安全备份已创建\",\n \"restoreFailed\": \"恢复失败\",\n \"safetyBackupId\": \"安全备份ID\",\n \"intervalLabel\": \"自动备份间隔\",\n \"retainLabel\": \"备份保留数量\",\n \"intervalDisabled\": \"禁用\",\n \"intervalHours\": \"{{hours}} 小时\",\n \"intervalDays\": \"{{days}} 天\",\n \"rename\": \"重命名\",\n \"renameSuccess\": \"备份重命名成功\",\n \"renameFailed\": \"重命名失败\",\n \"namePlaceholder\": \"输入新名称\",\n \"createBackup\": \"立即备份\",\n \"creating\": \"备份中...\",\n \"createSuccess\": \"备份创建成功\",\n \"createFailed\": \"备份创建失败\",\n \"delete\": \"删除\",\n \"deleting\": \"删除中...\",\n \"deleteSuccess\": \"备份已删除\",\n \"deleteFailed\": \"删除失败\",\n \"deleteConfirmTitle\": \"确认删除备份\",\n \"deleteConfirmMessage\": \"此备份将被永久删除,此操作无法撤消。\"\n },\n \"webdavSync\": {\n \"title\": \"WebDAV 云同步\",\n \"description\": \"通过 WebDAV 在多设备间同步数据库和技能配置。\",\n \"baseUrl\": \"WebDAV 服务器地址\",\n \"baseUrlPlaceholder\": \"https://dav.jianguoyun.com/dav/\",\n \"username\": \"WebDAV 账户\",\n \"usernamePlaceholder\": \"邮箱账号\",\n \"password\": \"WebDAV 密码\",\n \"passwordPlaceholder\": \"应用密码(坚果云请使用「第三方应用密码」)\",\n \"remoteRoot\": \"远程根目录\",\n \"profile\": \"同步配置名\",\n \"autoSync\": \"自动同步\",\n \"autoSyncHint\": \"开启后每次数据库变更都会自动上传到 WebDAV。\",\n \"test\": \"测试连接\",\n \"testing\": \"测试中...\",\n \"testSuccess\": \"连接成功\",\n \"testFailed\": \"连接失败:{{error}}\",\n \"save\": \"保存配置\",\n \"saving\": \"保存中...\",\n \"saveFailed\": \"保存配置失败:{{error}}\",\n \"upload\": \"上传到云端\",\n \"uploading\": \"上传中...\",\n \"uploadSuccess\": \"已上传到 WebDAV\",\n \"uploadFailed\": \"上传失败:{{error}}\",\n \"autoSyncFailedToast\": \"自动同步失败:{{error}}\",\n \"download\": \"从云端下载\",\n \"downloading\": \"下载中...\",\n \"downloadSuccess\": \"已从 WebDAV 下载并恢复\",\n \"downloadFailed\": \"下载失败:{{error}}\",\n \"lastSync\": \"上次同步:{{time}}\",\n \"autoSyncLastErrorTitle\": \"上次自动同步失败\",\n \"autoSyncLastErrorHint\": \"请检查网络或 WebDAV 配置,系统会在后续变更时继续自动重试。\",\n \"missingUrl\": \"请填写 WebDAV 服务器地址\",\n \"presets\": {\n \"label\": \"服务商\",\n \"jianguoyun\": \"坚果云\",\n \"jianguoyunHint\": \"请在坚果云「安全选项」中生成「第三方应用密码」,不要使用登录密码。\",\n \"nextcloud\": \"Nextcloud\",\n \"nextcloudHint\": \"请将 your-server 替换为你的 Nextcloud 服务器地址,USERNAME 替换为你的用户名。\",\n \"synology\": \"群晖 NAS\",\n \"synologyHint\": \"请先在群晖「套件中心」安装并启用 WebDAV Server 套件。\",\n \"custom\": \"自定义\"\n },\n \"remoteRootDefault\": \"默认: cc-switch-sync\",\n \"profileDefault\": \"默认: default\",\n \"saveAndTestSuccess\": \"配置已保存,连接正常\",\n \"saveAndTestFailed\": \"配置已保存,但连接测试失败:{{error}}\",\n \"noRemoteData\": \"云端没有找到同步数据\",\n \"incompatibleVersion\": \"远端数据版本不兼容(协议 v{{protocolVersion}},数据库 {{dbCompatVersion}}),当前支持协议 v2 / db-v6\",\n \"unsaved\": \"未保存\",\n \"saved\": \"已保存\",\n \"unsavedChanges\": \"请先保存配置\",\n \"saveBeforeSync\": \"请先保存配置,再使用上传/下载。\",\n \"fetchingRemote\": \"获取远端信息...\",\n \"fetchRemoteFailed\": \"获取远端信息失败,请检查配置和网络后重试。\",\n \"confirmDownload\": {\n \"title\": \"从云端恢复\",\n \"deviceName\": \"上传设备\",\n \"createdAt\": \"上传时间\",\n \"path\": \"远端路径\",\n \"dbCompat\": \"数据库兼容层\",\n \"artifacts\": \"包含内容\",\n \"legacyNotice\": \"检测到旧版云端路径。恢复完成后,下次上传将写入新路径 v2/db-v6。\",\n \"warning\": \"恢复将覆盖本地所有数据和技能配置\",\n \"confirm\": \"确认恢复\"\n },\n \"confirmUpload\": {\n \"title\": \"上传到云端\",\n \"content\": \"将同步以下内容到 WebDAV 服务器:\",\n \"dbItem\": \"数据库(所有 Provider 配置和数据)\",\n \"skillsItem\": \"技能包(所有自定义技能)\",\n \"targetPath\": \"目标路径\",\n \"existingData\": \"云端已有数据\",\n \"deviceName\": \"上传设备\",\n \"createdAt\": \"上传时间\",\n \"path\": \"远端路径\",\n \"dbCompat\": \"数据库兼容层\",\n \"warning\": \"将覆盖云端已有的同步数据\",\n \"legacyNotice\": \"检测到旧版云端路径数据。本次上传将写入新路径 v2/db-v6,不会覆盖旧路径。\",\n \"confirm\": \"确认上传\"\n }\n },\n \"syncType\": {\n \"label\": \"同步方式\",\n \"webdav\": \"WebDAV\",\n \"s3\": \"S3 兼容存储\"\n },\n \"s3Sync\": {\n \"presets\": {\n \"label\": \"服务商\",\n \"awsS3\": \"AWS S3\",\n \"awsS3Hint\": \"使用 IAM Access Key,需要 s3:PutObject/GetObject/HeadObject 权限。Region 示例:us-east-1, ap-northeast-1\",\n \"minio\": \"MinIO\",\n \"minioHint\": \"需要自定义 Endpoint,如 http://minio.local:9000。Region 可设为 us-east-1\",\n \"r2\": \"Cloudflare R2\",\n \"r2Hint\": \"Endpoint: https://<account_id>.r2.cloudflarestorage.com,Region 设为 auto\",\n \"oss\": \"阿里云 OSS\",\n \"ossHint\": \"Endpoint: https://oss-<region>.aliyuncs.com,Region 示例:cn-hangzhou, cn-shanghai\",\n \"cos\": \"腾讯云 COS\",\n \"cosHint\": \"Endpoint: https://cos.<region>.myqcloud.com,Region 示例:ap-guangzhou, ap-beijing\",\n \"obs\": \"华为云 OBS\",\n \"obsHint\": \"Endpoint: https://obs.<region>.myhuaweicloud.com,Region 示例:cn-north-4, cn-east-3\",\n \"custom\": \"S3 兼容服务(自定义)\",\n \"customHint\": \"任意 S3 兼容服务,需要自定义 Endpoint\"\n },\n \"region\": \"区域 (Region)\",\n \"regionPlaceholder\": \"例如 us-east-1\",\n \"bucket\": \"存储桶 (Bucket)\",\n \"bucketPlaceholder\": \"例如 my-cc-switch-bucket\",\n \"accessKeyId\": \"Access Key ID\",\n \"accessKeyIdPlaceholder\": \"例如 AKIA...\",\n \"secretAccessKey\": \"Secret Access Key\",\n \"secretAccessKeyPlaceholder\": \"留空则保持现有密钥\",\n \"endpoint\": \"Endpoint(AWS 可留空)\",\n \"endpointHint\": \"多数 S3 兼容服务需要填写\",\n \"endpointPlaceholder\": \"例如 https://s3-compatible.example.com\",\n \"remoteRoot\": \"远程根目录\",\n \"remoteRootDefault\": \"默认: cc-switch-sync\",\n \"profile\": \"同步配置名\",\n \"profileDefault\": \"默认: default\",\n \"autoSync\": \"自动同步\",\n \"autoSyncHint\": \"开启后每次数据库变更都会自动上传到 S3。\",\n \"enabled\": \"启用 S3 同步\",\n \"enabledHint\": \"开启后可使用 S3 上传/下载和自动同步。\",\n \"mutualExclusionTitle\": \"切换同步方式\",\n \"mutualExclusionMessage\": \"启用 S3 同步将禁用当前的 WebDAV 同步,是否继续?\",\n \"mutualExclusionMessageReverse\": \"启用 WebDAV 同步将禁用当前的 S3 同步,是否继续?\",\n \"mutualExclusionFailed\": \"切换同步方式失败:{{error}}\",\n \"testConnection\": \"测试连接\",\n \"connectionOk\": \"S3 连接成功\",\n \"test\": \"测试连接\",\n \"testing\": \"测试中...\",\n \"testSuccess\": \"S3 连接成功\",\n \"testFailed\": \"S3 连接失败:{{error}}\",\n \"save\": \"保存配置\",\n \"saving\": \"保存中...\",\n \"saveFailed\": \"保存配置失败:{{error}}\",\n \"saveAndTestSuccess\": \"配置已保存,S3 连接正常\",\n \"saveAndTestFailed\": \"配置已保存,但 S3 连接测试失败:{{error}}\",\n \"upload\": \"上传到云端\",\n \"uploading\": \"上传中...\",\n \"uploadSuccess\": \"已上传到 S3\",\n \"uploadFailed\": \"S3 上传失败:{{error}}\",\n \"autoSyncFailedToast\": \"S3 自动同步失败:{{error}}\",\n \"download\": \"从云端下载\",\n \"downloading\": \"下载中...\",\n \"downloadSuccess\": \"已从 S3 下载并恢复\",\n \"downloadFailed\": \"S3 下载失败:{{error}}\",\n \"lastSync\": \"上次同步:{{time}}\",\n \"autoSyncLastErrorTitle\": \"上次 S3 自动同步失败\",\n \"autoSyncLastErrorHint\": \"请检查网络或 S3 配置,系统会在后续变更时继续自动重试。\",\n \"missingBucket\": \"请填写 S3 存储桶\",\n \"noRemoteData\": \"S3 存储桶中没有找到同步数据\",\n \"incompatibleVersion\": \"远端数据版本不兼容(版本 {{version}}),当前支持协议 v2 / db-v6\",\n \"unsaved\": \"未保存\",\n \"saved\": \"已保存\",\n \"unsavedChanges\": \"请先保存配置\",\n \"saveBeforeSync\": \"请先保存配置,再使用上传/下载。\",\n \"fetchingRemote\": \"获取远端信息...\",\n \"fetchRemoteFailed\": \"获取远端信息失败,请检查配置和网络后重试。\",\n \"notConfigured\": \"未配置 S3 同步\",\n \"disabled\": \"S3 同步未启用\",\n \"confirmDownload\": {\n \"title\": \"从 S3 恢复\",\n \"deviceName\": \"上传设备\",\n \"createdAt\": \"上传时间\",\n \"artifacts\": \"包含内容\",\n \"warning\": \"恢复将覆盖本地所有数据和技能配置\",\n \"confirm\": \"确认恢复\"\n },\n \"confirmUpload\": {\n \"title\": \"上传到 S3\",\n \"content\": \"将同步以下内容到 S3 存储桶:\",\n \"dbItem\": \"数据库(所有 Provider 配置和数据)\",\n \"skillsItem\": \"技能包(所有自定义技能)\",\n \"targetPath\": \"目标对象路径\",\n \"existingData\": \"云端已有数据\",\n \"deviceName\": \"上传设备\",\n \"createdAt\": \"上传时间\",\n \"warning\": \"将覆盖该 S3 路径下已有的同步数据\",\n \"confirm\": \"确认上传\"\n }\n },\n \"autoReload\": \"数据已刷新\",\n \"languageOptionChinese\": \"简体中文\",\n \"languageOptionTraditionalChinese\": \"繁體中文\",\n \"languageOptionEnglish\": \"English\",\n \"languageOptionJapanese\": \"日本語\",\n \"windowBehavior\": \"窗口行为\",\n \"windowBehaviorHint\": \"配置窗口最小化与 Claude 插件联动策略。\",\n \"launchOnStartup\": \"开机自启\",\n \"launchOnStartupDescription\": \"随系统启动自动运行 CC Switch\",\n \"silentStartup\": \"静默启动\",\n \"silentStartupDescription\": \"程序启动时不显示主窗口,仅在系统托盘运行\",\n \"autoLaunchFailed\": \"设置开机自启失败\",\n \"minimizeToTray\": \"关闭时最小化到托盘\",\n \"minimizeToTrayDescription\": \"勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。\",\n \"useAppWindowControls\": \"启用应用级窗口按钮\",\n \"useAppWindowControlsDescription\": \"开启后使用应用自建的最小化、最大化/还原、关闭按钮;关闭后沿用系统窗口模式。\",\n \"enableClaudePluginIntegration\": \"应用到 Claude Code 插件\",\n \"enableClaudePluginIntegrationDescription\": \"开启后 VS Code Claude Code 插件的供应商将随本软件切换\",\n \"skipClaudeOnboarding\": \"跳过 Claude Code 初次安装确认\",\n \"skipClaudeOnboardingDescription\": \"开启后跳过 Claude Code 初次安装确认\",\n \"codexAuth\": \"Codex 应用增强\",\n \"preserveCodexOfficialAuthOnSwitch\": \"非接管切换时保留官方登录\",\n \"preserveCodexOfficialAuthOnSwitchDescription\": \"控制未开启路由接管时切换第三方供应商是否保留 Codex 官方登录;路由接管期间始终保留\",\n \"unifyCodexSessionHistory\": \"统一 Codex 会话历史\",\n \"unifyCodexSessionHistoryDescription\": \"开启后,官方订阅将以共享的 custom 供应商标识运行,官方与第三方会话出现在同一历史列表中,并可选择把现有官方会话一并迁入(迁移前自动备份)。关闭开关时可按备份恢复迁入的会话。注意:跨供应商继续旧会话时,对方后端可能无法解密会话中的 encrypted_content 推理内容,导致继续失败\",\n \"unifyCodexHistoryRestoreCompleted\": \"已按备份还原官方会话历史({{files}} 个会话文件、{{rows}} 条索引记录)\",\n \"unifyCodexHistoryRestoreFailed\": \"还原官方会话历史失败,请重试\",\n \"unifyCodexHistoryRestoreNothing\": \"当前 Codex 目录没有可恢复的迁移备份\",\n \"unifyCodexHistoryRestoreSkippedToggleOn\": \"统一会话历史开关已重新开启,已跳过还原\",\n \"appVisibility\": {\n \"title\": \"主页面显示\",\n \"description\": \"选择在主页面显示的应用\",\n \"claudeDesc\": \"Anthropic Claude Code CLI\",\n \"codexDesc\": \"OpenAI Codex CLI\",\n \"geminiDesc\": \"Google Gemini CLI\",\n \"opencodeDesc\": \"OpenCode CLI\",\n \"showProfileSwitcher\": \"显示项目切换\",\n \"showProfileSwitcherDescription\": \"在主页面顶部显示项目切换入口\"\n },\n \"skillStorage\": {\n \"title\": \"Skills 存储位置\",\n \"description\": \"选择 CC Switch 存放 Skills 主副本的目录\",\n \"ccSwitch\": \"CC Switch\",\n \"unified\": \"~/.agents/skills\",\n \"ccSwitchHint\": \"技能存储在 ~/.cc-switch/skills/,由 CC Switch 统一管理并同步到各应用。\",\n \"unifiedHint\": \"技能存储在 ~/.agents/skills/,遵循 Agent Skills 开放标准。兼容的工具(Claude Code、Codex、Gemini CLI 等)可直接发现此目录中的技能。\",\n \"confirmTitle\": \"迁移技能存储\",\n \"confirmMessage\": \"将移动 {{count}} 个技能到新位置,是否继续?\",\n \"migrationSuccess\": \"已成功迁移 {{count}} 个技能\",\n \"migrationPartial\": \"迁移了 {{migrated}} 个技能,{{errors}} 个失败,请查看日志\"\n },\n \"skillSync\": {\n \"title\": \"Skills 同步方式\",\n \"description\": \"选择 Skills 的文件同步策略\",\n \"symlink\": \"软连接\",\n \"copy\": \"文件复制\",\n \"symlinkHint\": \"软连接节省磁盘空间并支持实时同步。注意:Windows 可能需要管理员权限或开启开发者模式\"\n },\n \"terminal\": {\n \"title\": \"首选终端\",\n \"description\": \"选择点击终端按钮时使用的终端应用\",\n \"fallbackHint\": \"如果选择的终端不可用,将自动使用系统默认终端\",\n \"options\": {\n \"macos\": {\n \"terminal\": \"Terminal.app\",\n \"iterm2\": \"iTerm2\",\n \"alacritty\": \"Alacritty\",\n \"kitty\": \"Kitty\",\n \"ghostty\": \"Ghostty\",\n \"wezterm\": \"WezTerm\",\n \"kaku\": \"Kaku\",\n \"warp\": \"Warp\"\n },\n \"windows\": {\n \"cmd\": \"命令提示符\",\n \"powershell\": \"PowerShell\",\n \"wt\": \"Windows Terminal\"\n },\n \"linux\": {\n \"gnomeTerminal\": \"GNOME Terminal\",\n \"konsole\": \"Konsole\",\n \"xfce4Terminal\": \"Xfce4 Terminal\",\n \"alacritty\": \"Alacritty\",\n \"kitty\": \"Kitty\",\n \"ghostty\": \"Ghostty\"\n }\n }\n },\n \"configDirectoryOverride\": \"配置目录覆盖(高级)\",\n \"configDirectoryDescription\": \"在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。\",\n \"appConfigDir\": \"CC Switch 配置目录\",\n \"appConfigDirDescription\": \"自定义 CC Switch 的配置存储位置(指定到云同步文件夹即可云同步配置)\",\n \"browsePlaceholderApp\": \"例如:C:\\\\Users\\\\Administrator\\\\.cc-switch\",\n \"claudeConfigDir\": \"Claude Code 配置目录\",\n \"claudeConfigDirDescription\": \"覆盖 Claude 配置目录 (settings.json)。真正自定义目录会在目录内存放 Claude MCP 的 .claude.json;默认 ~/.claude 目录仍使用 Claude 默认的 ~/.claude.json。\",\n \"codexConfigDir\": \"Codex 配置目录\",\n \"codexConfigDirDescription\": \"覆盖 Codex 配置目录。\",\n \"geminiConfigDir\": \"Gemini 配置目录\",\n \"geminiConfigDirDescription\": \"覆盖 Gemini 配置目录 (.env)。\",\n \"opencodeConfigDir\": \"OpenCode 配置目录\",\n \"opencodeConfigDirDescription\": \"覆盖 OpenCode 配置目录 (opencode.json)。\",\n \"openclawConfigDir\": \"OpenClaw 配置目录\",\n \"openclawConfigDirDescription\": \"覆盖 OpenClaw 配置目录 (openclaw.json)。\",\n \"hermesConfigDir\": \"Hermes 配置目录\",\n \"hermesConfigDirDescription\": \"覆盖 Hermes 配置目录 (config.yaml)。\",\n \"browsePlaceholderClaude\": \"例如:/home/<你的用户名>/.claude\",\n \"browsePlaceholderCodex\": \"例如:/home/<你的用户名>/.codex\",\n \"browsePlaceholderGemini\": \"例如:/home/<你的用户名>/.gemini\",\n \"browsePlaceholderOpencode\": \"例如:/home/<你的用户名>/.config/opencode\",\n \"browsePlaceholderOpenclaw\": \"例如:/home/<你的用户名>/.openclaw\",\n \"browsePlaceholderHermes\": \"例如:/home/<你的用户名>/.hermes\",\n \"browseDirectory\": \"浏览目录\",\n \"resetDefault\": \"恢复默认目录(需保存后生效)\",\n \"checkForUpdates\": \"检查更新\",\n \"updateTo\": \"更新到 v{{version}}\",\n \"updating\": \"更新中...\",\n \"checking\": \"检查中...\",\n \"upToDate\": \"已是最新\",\n \"aboutHint\": \"查看版本信息与更新状态。\",\n \"portableMode\": \"当前为便携版,更新需手动下载。\",\n \"updateAvailable\": \"检测到新版本:{{version}}\",\n \"updateBadge\": \"有更新可用\",\n \"updateFailed\": \"更新安装失败,已尝试打开下载页面。\",\n \"checkUpdateFailed\": \"检查更新失败,请稍后重试。\",\n \"openReleaseNotesFailed\": \"打开更新日志失败\",\n \"releaseNotes\": \"更新日志\",\n \"viewReleaseNotes\": \"查看该版本更新日志\",\n \"viewCurrentReleaseNotes\": \"查看当前版本更新日志\",\n \"officialWebsite\": \"官方网站\",\n \"github\": \"GitHub\",\n \"manualInstallCommands\": \"手动安装命令\",\n \"oneClickInstallHint\": \"安装或升级 Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes\",\n \"localEnvCheck\": \"本地环境检查\",\n \"updateAllTools\": \"全部升级({{count}})\",\n \"currentVersion\": \"当前版本\",\n \"latestVersion\": \"最新版本\",\n \"updateAvailableShort\": \"可升级\",\n \"toolInstall\": \"安装\",\n \"toolUpdate\": \"升级\",\n \"toolReady\": \"已就绪\",\n \"toolActionDone\": \"{{count}} 个工具{{action}}完成\",\n \"toolActionPartial\": \"{{succeeded}} 个{{action}}成功,{{failed}} 个失败\",\n \"toolActionFailed\": \"安装/升级命令执行失败\",\n \"toolNotRunnable\": \"已安装但无法运行(未探测到版本)\",\n \"toolActionVersionUnchangedTitle\": \"版本没有变化\",\n \"toolActionVersionUnchanged\": \"仍是 {{version}}(最新:{{latest}})。上游升级器可能报告成功但没有实际更新。\",\n \"toolActionInstalledNotRunnable\": \"已安装,但当前环境无法运行,请检查\",\n \"installedNotRunnable\": \"已安装·无法运行\",\n \"toolCheckEnv\": \"请检查运行环境\",\n \"toolDiagnose\": \"诊断安装冲突\",\n \"toolDiagnosing\": \"诊断中…\",\n \"toolConflictTitle\": \"检测到多处安装\",\n \"toolConflictHint\": \"命令行实际使用标「默认」的那处;升级可能写到了别处。\",\n \"toolConflictDefault\": \"默认\",\n \"toolConflictNotRunnable\": \"无法运行\",\n \"toolDiagnoseNoConflict\": \"未发现安装冲突\",\n \"toolDiagnoseFailed\": \"诊断失败\",\n \"toolUpgradeConfirmTitle\": \"确认升级位置\",\n \"toolUpgradeConfirmHint\": \"检测到多处安装。本次升级不会全部更新,具体作用的位置以下方各项为准。\",\n \"toolUpgradeWillRun\": \"将执行:\",\n \"toolUpgradeConfirmBtn\": \"确认升级\",\n \"toolUpgradeUnanchoredHint\": \"无法确定命令行实际使用哪处,将执行默认升级命令(可能装到 PATH 上第一个 npm)。\",\n \"envBadge\": {\n \"wsl\": \"WSL\",\n \"windows\": \"Win\",\n \"macos\": \"macOS\",\n \"linux\": \"Linux\"\n },\n \"wslShell\": \"Shell\",\n \"wslShellFlag\": \"标志\",\n \"installCommandsCopied\": \"安装命令已复制\",\n \"installCommandsCopyFailed\": \"复制失败,请手动复制。\",\n \"importFailedError\": \"导入配置失败:{{message}}\",\n \"exportFailedError\": \"导出配置失败:\",\n \"restartRequired\": \"需要重启应用\",\n \"restartRequiredMessage\": \"修改 CC Switch 配置目录后需要重启应用才能生效,是否立即重启?\",\n \"restartNow\": \"立即重启\",\n \"restartLater\": \"稍后重启\",\n \"restartFailed\": \"应用重启失败,请手动关闭后重新打开。\",\n \"devModeRestartHint\": \"开发模式下不支持自动重启,请手动重新启动应用。\",\n \"saving\": \"正在保存...\",\n \"globalProxy\": {\n \"label\": \"全局代理\",\n \"hint\": \"代理所有请求(API、Skills 下载等)。开启本地路由时,应用的请求也会经过此代理。留空表示直连。\",\n \"username\": \"用户名(可选)\",\n \"password\": \"密码(可选)\",\n \"test\": \"测试连接\",\n \"scan\": \"扫描本地代理\",\n \"clear\": \"清除\",\n \"scanFailed\": \"扫描失败:{{error}}\",\n \"saved\": \"代理设置已保存\",\n \"saveFailed\": \"保存失败:{{error}}\",\n \"testSuccess\": \"连接成功!延迟 {{latency}}ms\",\n \"testFailed\": \"连接失败:{{error}}\",\n \"pricingDefaultsTitle\": \"计费默认配置\",\n \"pricingDefaultsDescription\": \"设置各应用的默认倍率与计费模式来源。\",\n \"pricingAppLabel\": \"应用\",\n \"defaultCostMultiplierLabel\": \"默认倍率\",\n \"defaultCostMultiplierHint\": \"用于成本计算的倍率,支持小数。\",\n \"pricingModelSourceLabel\": \"计费模式\",\n \"pricingModelSourceRequest\": \"请求模型\",\n \"pricingModelSourceResponse\": \"返回模型\",\n \"pricingSave\": \"保存计费配置\",\n \"pricingSaved\": \"计费配置已保存\",\n \"pricingSaveFailed\": \"保存计费配置失败:{{error}}\",\n \"pricingLoadFailed\": \"加载计费配置失败:{{error}}\",\n \"defaultCostMultiplierRequired\": \"默认倍率不能为空\",\n \"defaultCostMultiplierInvalid\": \"默认倍率格式不正确\"\n },\n \"saveFailedGeneric\": \"保存失败,请重试\"\n },\n \"apps\": {\n \"claude\": \"Claude\",\n \"claudeCode\": \"Claude Code\",\n \"claudeDesktop\": \"Claude Desktop\",\n \"claude-desktop\": \"Claude Desktop\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\",\n \"openclaw\": \"OpenClaw\",\n \"hermes\": \"Hermes\",\n \"grok\": \"Grok Build\"\n },\n \"sessionManager\": {\n \"title\": \"会话管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 与 Gemini CLI 会话记录\",\n \"searchPlaceholder\": \"搜索会话内容、目录或 ID\",\n \"searchSessions\": \"搜索会话\",\n \"providerFilterAll\": \"全部\",\n \"providerFilterTooltip\": \"供应商筛选\",\n \"sessionList\": \"会话列表\",\n \"viewModeTooltip\": \"查看方式\",\n \"viewModeFlat\": \"列表\",\n \"viewModeGrouped\": \"分类\",\n \"unknownDirectory\": \"未知目录\",\n \"toggleProviderGroup\": \"展开或折叠 {{provider}} 供应商分组\",\n \"toggleDirectoryGroup\": \"展开或折叠 {{directory}} 目录分组\",\n \"collapseAllGroups\": \"全部收起\",\n \"selectProviderGroupForBatch\": \"选择 {{provider}} 供应商分组内会话\",\n \"selectDirectoryGroupForBatch\": \"选择 {{directory}} 目录分组内会话\",\n \"manageBatchTooltip\": \"进入批量管理\",\n \"exitBatchModeTooltip\": \"退出批量管理\",\n \"batchModeHint\": \"勾选要删除的会话\",\n \"selectForBatch\": \"选择会话\",\n \"selectedCount\": \"已选 {{count}} 项\",\n \"selectAllFiltered\": \"全选当前\",\n \"clearFilteredSelection\": \"取消全选\",\n \"clearSelection\": \"清空已选\",\n \"deleteSelected\": \"批量删除\",\n \"batchDeleting\": \"删除中...\",\n \"loadingSessions\": \"加载会话中...\",\n \"noSessions\": \"未发现会话\",\n \"selectSession\": \"请选择会话查看详情\",\n \"noSummary\": \"暂无摘要\",\n \"lastActive\": \"最近活跃\",\n \"projectDir\": \"项目目录\",\n \"sourcePath\": \"原始文件\",\n \"copyResumeCommand\": \"复制恢复命令\",\n \"resumeCommandCopied\": \"恢复命令已复制\",\n \"openInTerminal\": \"在终端恢复\",\n \"terminalTargetTerminal\": \"Terminal\",\n \"terminalTargetKitty\": \"kitty\",\n \"terminalTargetCopy\": \"仅复制\",\n \"terminalLaunched\": \"终端已启动\",\n \"openFailed\": \"终端启动失败\",\n \"resumeFallbackCopied\": \"已复制恢复命令,可手动粘贴到终端\",\n \"copyProjectDir\": \"复制目录\",\n \"projectDirCopied\": \"目录已复制\",\n \"copySourcePath\": \"复制原始文件\",\n \"sourcePathCopied\": \"原始文件已复制\",\n \"delete\": \"删除会话\",\n \"deleting\": \"删除中...\",\n \"deleteTooltip\": \"永久删除此本地会话记录\",\n \"deleteConfirmTitle\": \"删除会话\",\n \"deleteConfirmMessage\": \"将永久删除本地会话“{{title}}”\\nSession ID: {{sessionId}}\\n\\n此操作不可恢复。\",\n \"deleteConfirmAction\": \"删除会话\",\n \"sessionDeleted\": \"会话已删除\",\n \"deleteFailed\": \"删除会话失败: {{error}}\",\n \"batchDeleteConfirmTitle\": \"批量删除会话\",\n \"batchDeleteConfirmMessage\": \"将永久删除已选中的 {{count}} 个本地会话记录。\\n\\n此操作不可恢复。\",\n \"batchDeleteConfirmAction\": \"删除所选会话\",\n \"batchDeleteSuccess\": \"已删除 {{count}} 个会话\",\n \"batchDeleteFailed\": \"{{failed}} 个会话删除失败\",\n \"batchDeleteRequestFailed\": \"批量删除失败,请稍后重试\",\n \"loadingMessages\": \"加载会话内容中...\",\n \"emptySession\": \"该会话暂无可展示内容\",\n \"clickToCopyPath\": \"点击复制路径\",\n \"tocTitle\": \"对话目录\",\n \"justNow\": \"刚刚\",\n \"minutesAgo\": \"{{count}} 分钟前\",\n \"hoursAgo\": \"{{count}} 小时前\",\n \"daysAgo\": \"{{count}} 天前\",\n \"roleUser\": \"用户\",\n \"roleSystem\": \"系统\",\n \"roleTool\": \"工具\",\n \"resume\": \"恢复会话\",\n \"resumeTooltip\": \"在终端中恢复此会话\",\n \"noResumeCommand\": \"此会话无法恢复\",\n \"copyCommand\": \"复制命令\",\n \"copyMessage\": \"复制消息\",\n \"messageCopied\": \"已复制消息内容\",\n \"conversationHistory\": \"对话记录\",\n \"expandContent\": \"展开完整内容\",\n \"collapseContent\": \"收起\"\n },\n \"console\": {\n \"providerSwitchReceived\": \"收到供应商切换事件:\",\n \"setupListenerFailed\": \"设置供应商切换监听器失败:\",\n \"updateProviderFailed\": \"更新供应商失败:\",\n \"autoImportFailed\": \"自动导入默认配置失败:\",\n \"openLinkFailed\": \"打开链接失败:\",\n \"getVersionFailed\": \"获取版本信息失败:\",\n \"loadSettingsFailed\": \"加载设置失败:\",\n \"getConfigPathFailed\": \"获取配置路径失败:\",\n \"getConfigDirFailed\": \"获取配置目录失败:\",\n \"detectPortableFailed\": \"检测便携模式失败:\",\n \"saveSettingsFailed\": \"保存设置失败:\",\n \"updateFailed\": \"更新失败:\",\n \"checkUpdateFailed\": \"检查更新失败:\",\n \"openConfigFolderFailed\": \"打开配置文件夹失败:\",\n \"selectConfigDirFailed\": \"选择配置目录失败:\",\n \"getDefaultConfigDirFailed\": \"获取默认配置目录失败:\",\n \"openReleaseNotesFailed\": \"打开更新日志失败:\"\n },\n \"providerForm\": {\n \"supplierName\": \"供应商名称\",\n \"supplierNameRequired\": \"供应商名称 *\",\n \"supplierNamePlaceholder\": \"例如:Anthropic 官方\",\n \"websiteUrl\": \"官网地址\",\n \"websiteUrlPlaceholder\": \"https://example.com(可选)\",\n \"apiEndpoint\": \"请求地址\",\n \"apiEndpointPlaceholder\": \"https://your-api-endpoint.com\",\n \"codexApiEndpointPlaceholder\": \"https://your-api-endpoint.com/v1\",\n \"manageAndTest\": \"管理与测速\",\n \"configContent\": \"配置内容\",\n \"officialNoApiKey\": \"官方登录无需填写 API Key,直接保存即可\",\n \"codexOfficialNoApiKey\": \"官方无需填写 API Key,直接保存即可\",\n \"codexApiKeyAutoFill\": \"只需要填这里,下方 auth.json 会自动填充\",\n \"apiKeyAutoFill\": \"只需要填这里,下方配置会自动填充\",\n \"cnOfficialApiKeyHint\": \"💡 只需填写 API Key,请求地址已预设\",\n \"aggregatorApiKeyHint\": \"💡 只需填写 API Key,请求地址已预设\",\n \"thirdPartyApiKeyHint\": \"💡 只需填写 API Key,请求地址已预设\",\n \"customApiKeyHint\": \"💡 自定义配置需手动填写所有必要字段\",\n \"omoHint\": \"💡 OMO 配置管理 Agent 模型分配,兼容 oh-my-openagent.jsonc / oh-my-opencode.jsonc\",\n \"officialHint\": \"💡 官方供应商使用浏览器登录,无需配置 API Key\",\n \"getApiKey\": \"获取 API Key\",\n \"partnerPromotion\": {\n \"sudocode\": \"SudoCode 让 Claude Code 与 Claude Desktop 接入 Claude Opus 4.8,Codex 接入 GPT-5.6,一个 Key 统一使用。CC Switch 用户注册并加入 QQ 群 726213516,联系群主领取 ¥10 试用额度。\",\n \"code0\": \"code0.ai 是面向开发者的 AI 编程服务平台,支持 Claude Code、Codex、Gemini。CC Switch 用户专属福利:通过官网联系客服即可领取测试额度!\",\n \"nekocode\": \"NekoCode 为开发者提供稳定、高效、可靠的 Claude、Codex 等 AI 模型 API 中转服务,价格透明、按量计费。CC Switch 用户专享 9 折:通过上方链接注册,充值时输入优惠码 cc-switch 即享 9 折优惠!\",\n \"packycode\": \"PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \\\"cc-switch\\\" 优惠码,可以享受9折优惠\",\n \"apikeyfun\": \"APIKEY.FUN 为 CC Switch 的用户提供了特别优惠,通过专属链接注册,可享受最高充值永久 95 折优惠。\",\n \"apinebula\": \"APINEBULA 为 CC Switch 用户提供特别优惠:使用专属链接注册并在充值时填写 \\\"ccswitch\\\" 优惠码,可享九折优惠。\",\n \"atlascloud\": \"Atlas Cloud 是一个全模态 AI 推理平台,通过单一 API 为开发者提供视频生成、图像生成及 LLM 接入。立即查看全新“编程计划”优惠,获取更具性价比的 API 接入。\",\n \"patewayai\": \"PatewayAI 为 CC Switch 的用户提供了特别福利,通过此链接注册可以获得3美元额度。\",\n \"claudeapi\": \"ClaudeAPI 为 CC Switch 的用户提供了特别福利,通过此链接注册可以领取测试额度。\",\n \"claudecn\": \"ClaudeCN 是一家实体企业运营的企业级AI中转平台,支持企业采购流程,可对公打款、签约,服务合规有保障。\",\n \"runapi\": \"RunAPI 为 CC Switch 的用户提供了特别福利,通过此链接注册并联系客服可领取 ¥14 免费额度。\",\n \"minimax_cn\": \"MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起\",\n \"minimax_en\": \"MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)\",\n \"opencode_go\": \"使用此链接订阅 OpenCode Go,首月只需 $5,并可获得额外的 $5 额度!\",\n \"dmxapi\": \"Claude Code 专属模型 3.4 折优惠进行中!\",\n \"cubence\": \"Cubence 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \\\"CCSWITCH\\\" 优惠码,每次充值均可享受9折优惠\",\n \"aigocode\": \"AIGoCode 是 CC Switch 的官方合作伙伴,使用此链接注册首次充值时可以获得10%额度奖励!\",\n \"rightcode\": \"RightCode 是 CC Switch 的官方合作伙伴,使用此链接注册每次充值均可赠送5%额外额度!\",\n \"aicodemirror\": \"AICodeMirror 是 CC Switch 的官方合作伙伴,使用此链接注册可享受8折优惠!\",\n \"crazyrouter\": \"CrazyRouter 为 CC Switch 的用户提供了特殊优惠,首次充值赠予 30% 额外额度!\",\n \"sssaicode\": \"SSAI Code 为 CC Switch 的用户提供了特殊优惠,每次充值赠予额外 $10 额度!\",\n \"siliconflow\": \"硅基流动是 CC Switch 的官方合作伙伴\",\n \"ucloud\": \"优云智算为CC Switch 的用户提供了特殊优惠,通过此链接注册,可以获得五元平台体验金!\",\n \"micu\": \"Micu 是 CC Switch 的官方合作伙伴\",\n \"etok\": \"官网加入ETok社群,订阅套餐。\",\n \"shengsuanyun\": \"胜算云为 CC Switch 的用户提供了特别福利,使用此链接注册的新用户可获 10 元模力及首充 10% 赠送!\",\n \"doubaoseed\": \"豆包大模型为 CC Switch 的用户提供了专属福利:通过此链接注册即可获取豆包全系列文本模型 50万tokens推理额度以及Seedance2.0的500万tokens免费额度\",\n \"volcengine_agentplan\": \"火山方舟 Coding Plan 为 CC Switch 的用户提供了专属福利:通过此链接订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠,再用专属邀请码 6J6FV5N2 领取奖励叠加 9.5 折,低至 9.4 元/月!\",\n \"byteplus\": \"通过此链接注册即可获取每个模型50万tokens的免费推理额度。\",\n \"ccsub\": \"CCSub 是稳定、实惠的 AI API 中转平台,Claude Code 官方订阅的超强平替,一个 Key 覆盖全部模型,价格约为官方 1/3。\",\n \"subrouter\": \"SubRouter 是中转站聚合平台,一个 Key 可接入多个模型与供应商。\",\n \"qiniu\": \"七牛云是多协议 AI 中转平台,一个 Key 即可调用原生 Claude、GPT、Gemini 模型,兼容 Anthropic、OpenAI、Gemini 多种接口。\",\n \"fenno\": \"Fenno.ai 是稳定高效的 API 中转服务商,可接入多种主流模型。CC Switch 用户专属福利:通过此链接订阅 9.9 元 /150 刀额度的超值 Coding Plan!\",\n \"zetaapi\": \"ZetaAPI 主打模型不掺水、不降智,价格低至官方价 35 折。CC Switch 用户专属福利:通过此链接注册,首次充值使用优惠码 CC-SWITCH 即可享受九折优惠!\",\n \"teamorouter\": \"TeamoRouter 是企业级 Agentic LLM 网关,价格最高可达 90% 折扣。为 CC Switch 的用户提供了特别福利:新用户通过此链接注册,首次充值享 10% 折扣!\",\n \"unity2\": \"Unity2.ai 为 CC Switch 用户提供专属福利:通过此链接注册可领取 $2 余额,加入官方群再送 $10,最高可领 $12 免费额度!\"\n },\n \"presets\": {\n \"ucloud\": \"优云智算\",\n \"ucloudCoding\": \"优云智算Coding Plan\",\n \"shengsuanyun\": \"胜算云\",\n \"qiniu\": \"七牛云\",\n \"openrouter\": \"OpenRouter\",\n \"deepseek\": \"DeepSeek\",\n \"together\": \"Together AI\"\n },\n \"parameterConfig\": \"参数配置 - {{name}} *\",\n \"mainModel\": \"主模型 (可选)\",\n \"mainModelPlaceholder\": \"例如: GLM-4.6\",\n \"fastModel\": \"快速模型 (可选)\",\n \"fastModelPlaceholder\": \"例如: GLM-4.5-Air\",\n \"modelHint\": \"💡 留空将使用供应商的默认模型\",\n \"apiHint\": \"💡 填写兼容 Claude API 的服务端点地址,不要以斜杠结尾\",\n \"apiHintOAI\": \"💡 填写兼容 OpenAI Chat Completions 的服务端点地址,不要以斜杠结尾\",\n \"apiHintGeminiNative\": \"💡 建议填写 Gemini Native 的 base URL,例如 https://generativelanguage.googleapis.com 或 https://generativelanguage.googleapis.com/v1beta;代理会自动补全 models/*:generateContent\",\n \"codexApiHint\": \"💡 填写兼容 OpenAI Response 格式的服务端点地址\",\n \"fillSupplierName\": \"请填写供应商名称\",\n \"fillConfigContent\": \"请填写配置内容\",\n \"fillParameter\": \"请填写 {{label}}\",\n \"fillTemplateValue\": \"请填写 {{label}}\",\n \"endpointRequired\": \"非官方供应商请填写 API 端点\",\n \"apiKeyRequired\": \"非官方供应商请填写 API Key\",\n \"softValidation\": {\n \"title\": \"配置存在以下问题\",\n \"hint\": \"仍要保存吗?保存后切换此供应商时可能失败,可以之后再补全。\",\n \"saveAnyway\": \"仍要保存\"\n },\n \"configJsonError\": \"配置JSON格式错误,请检查语法\",\n \"authJsonRequired\": \"auth.json 必须是 JSON 对象\",\n \"authJsonError\": \"auth.json 格式错误,请检查JSON语法\",\n \"fillAuthJson\": \"请填写 auth.json 配置\",\n \"fillApiKey\": \"请填写 OPENAI_API_KEY\",\n \"visitWebsite\": \"访问 {{url}}\",\n \"anthropicModel\": \"主模型\",\n \"anthropicSmallFastModel\": \"快速模型\",\n \"apiFormat\": \"API 格式\",\n \"apiFormatHint\": \"选择供应商 API 的输入格式\",\n \"customUserAgent\": \"自定义 User-Agent\",\n \"customUserAgentHint\": \"仅在开启本地路由/代理接管后生效,会替换转发到供应商 API 请求中的 User-Agent。\",\n \"customUserAgentInvalid\": \"User-Agent 不能包含控制字符(如换行符),否则将被忽略。\",\n \"customUserAgentPresets\": \"预设\",\n \"localProxyRequestOverrides\": \"本地代理请求覆盖\",\n \"localProxyRequestOverridesHint\": \"仅在本地路由/代理接管后生效,应用于协议转换后的上游请求。\",\n \"localProxyRequestOverridesInvalid\": \"本地代理请求覆盖格式错误:{{error}}\",\n \"localProxyHeaderOverrides\": \"Header 覆盖\",\n \"localProxyHeaderOverridesInvalidDetail\": \"Header 覆盖格式错误:{{error}}\",\n \"localProxyBodyOverrides\": \"Body 覆盖\",\n \"localProxyBodyOverridesInvalidDetail\": \"Body 覆盖格式错误:{{error}}\",\n \"fullUrlLabel\": \"完整 URL\",\n \"fullUrlEnabled\": \"完整 URL 模式\",\n \"fullUrlDisabled\": \"标记为完整 URL\",\n \"fullUrlHint\": \"💡 请填写完整请求 URL,并且必须开启路由后使用;路由将直接使用此 URL,不拼接路径\",\n \"fullUrlHintGeminiNative\": \"💡 Gemini Native 下,完整 URL 模式同时兼容两类地址:1. 官方/标准 Gemini URL,代理会按模型和流式参数自动归一化;2. 自定义 relay 的完整 URL,代理会尽量原样使用,只补查询参数,不再强行追加 models 路径\",\n \"apiFormatAnthropic\": \"Anthropic Messages (原生)\",\n \"apiFormatOpenAIChat\": \"OpenAI Chat Completions (需开启路由)\",\n \"apiFormatOpenAIResponses\": \"OpenAI Responses API (需开启路由)\",\n \"apiFormatGeminiNative\": \"Gemini Native generateContent (需开启路由)\",\n \"codexApiFormatResponses\": \"OpenAI Responses API (原生)\",\n \"codexApiFormatOpenAIChat\": \"OpenAI Chat Completions (需开启路由)\",\n \"codexApiFormatHint\": \"选择供应商真实支持的 Codex API 格式;配置仍保持 Responses 以兼容新版 Codex,Chat Completions 会通过本地路由自动转换。\",\n \"authField\": \"认证字段\",\n \"authFieldAuthToken\": \"ANTHROPIC_AUTH_TOKEN(默认)\",\n \"authFieldApiKey\": \"ANTHROPIC_API_KEY\",\n \"authFieldHint\": \"选择写入配置的认证环境变量名\",\n \"apiHintResponses\": \"💡 填写兼容 OpenAI Responses API 的服务端点地址,不要以斜杠结尾\",\n \"anthropicDefaultHaikuModel\": \"Haiku 默认模型\",\n \"anthropicDefaultSonnetModel\": \"Sonnet 默认模型\",\n \"anthropicDefaultOpusModel\": \"Opus 默认模型\",\n \"modelPlaceholder\": \"\",\n \"smallModelPlaceholder\": \"\",\n \"haikuModelPlaceholder\": \"\",\n \"modelHelper\": \"可选:指定默认使用的 Claude 模型,留空则使用系统默认。\",\n \"modelMappingLabel\": \"模型映射\",\n \"modelMappingHint\": \"显示名称只影响 /model 菜单;1M 只是给 Claude Code 的上下文能力声明。\",\n \"modelRoleLabel\": \"模型角色\",\n \"modelRoleSonnet\": \"Sonnet\",\n \"modelRoleOpus\": \"Opus\",\n \"modelRoleFable\": \"Fable\",\n \"modelRoleHaiku\": \"Haiku\",\n \"modelRoleSubagent\": \"Subagent\",\n \"modelDisplayNameLabel\": \"显示名称\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"modelNoDisplayName\": \"不显示在 /model 菜单\",\n \"requestModelLabel\": \"实际请求模型\",\n \"modelOneMHeader\": \"声明支持 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"默认兜底模型\",\n \"fallbackModelHint\": \"用于未明确落到 Sonnet、Opus、Fable、Haiku 角色的请求。使用第三方/中转端点时建议填写:否则这些请求(含 Haiku 后台子任务)会以原始 Claude 模型名透传给上游,可能因上游无此模型而报错。官方端点可留空。\",\n \"quickSetModels\": \"一键设置\",\n \"quickSetSuccess\": \"已将模型名称应用到所有角色\",\n \"advancedOptionsToggle\": \"高级选项\",\n \"advancedOptionsHint\": \"包含 API 格式、认证字段、模型映射等配置。大多数场景下保持默认即可。\",\n \"categoryOfficial\": \"官方\",\n \"categoryCnOfficial\": \"开源官方\",\n \"categoryAggregation\": \"聚合服务\",\n \"categoryThirdParty\": \"第三方\",\n \"fetchModels\": \"获取模型列表\",\n \"fetchingModels\": \"正在获取...\",\n \"fetchModelsSuccess\": \"获取到 {{count}} 个模型\",\n \"fetchModelsFailed\": \"获取模型列表失败\",\n \"fetchModelsEmpty\": \"未找到可用模型\",\n \"fetchModelsNeedApiKey\": \"请先填写 API Key\",\n \"fetchModelsNeedEndpoint\": \"请先填写 API 端点\",\n \"fetchModelsNeedConfig\": \"请先填写 API 端点和 API Key\",\n \"fetchModelsAuthFailed\": \"API Key 无效或无权限\",\n \"fetchModelsNotSupported\": \"该供应商不支持获取模型列表\",\n \"fetchModelsEndpointNotFound\": \"未找到可用的模型列表端点,请检查 Base URL 或确认供应商是否开放该接口\",\n \"fetchModelsTimeout\": \"请求超时,请检查网络连接\"\n },\n \"copilot\": {\n \"authSection\": \"GitHub Copilot 认证\",\n \"authStatus\": \"认证状态\",\n \"authenticated\": \"已认证: {{username}}\",\n \"notAuthenticated\": \"未认证\",\n \"loginWithGitHub\": \"使用 GitHub 登录\",\n \"loginRequired\": \"请先登录 GitHub Copilot\",\n \"waitingForAuth\": \"等待授权中...\",\n \"enterCode\": \"请在浏览器中输入验证码:\",\n \"logout\": \"注销\",\n \"authSuccess\": \"GitHub Copilot 认证成功\",\n \"authFailed\": \"认证失败: {{error}}\",\n \"authTimeout\": \"认证超时,请重试\",\n \"tokenExpired\": \"令牌已过期,请重新认证\",\n \"accountCount\": \"{{count}} 个账号\",\n \"selectAccount\": \"选择账号\",\n \"selectAccountPlaceholder\": \"选择一个 GitHub 账号\",\n \"useDefaultAccount\": \"使用默认账号\",\n \"loggedInAccounts\": \"已登录账号\",\n \"defaultAccount\": \"默认\",\n \"selected\": \"已选中\",\n \"removeAccount\": \"移除账号\",\n \"setAsDefault\": \"设为默认\",\n \"addAnotherAccount\": \"添加其他账号\",\n \"logoutAll\": \"注销所有账号\",\n \"retry\": \"重试\",\n \"copyCode\": \"复制代码\",\n \"migrationFailed\": \"旧认证数据迁移失败:{{error}}\",\n \"loadModelsFailed\": \"加载 Copilot 模型列表失败\",\n \"deploymentType\": \"GitHub 部署类型\",\n \"deploymentGitHubCom\": \"GitHub.com\",\n \"deploymentEnterprise\": \"GitHub Enterprise Server\",\n \"enterpriseDomainPlaceholder\": \"例如:company.ghe.com\"\n },\n \"codexOauth\": {\n \"authStatus\": \"认证状态\",\n \"notAuthenticated\": \"未认证\",\n \"loginWithChatGPT\": \"使用 ChatGPT 登录\",\n \"loginRequired\": \"请先登录 ChatGPT 账号\",\n \"waitingForAuth\": \"等待授权中...\",\n \"enterCode\": \"请在浏览器中输入以下验证码:\",\n \"accountCount\": \"{{count}} 个账号\",\n \"selectAccount\": \"选择账号\",\n \"selectAccountPlaceholder\": \"选择一个 ChatGPT 账号\",\n \"useDefaultAccount\": \"使用默认账号\",\n \"loggedInAccounts\": \"已登录账号\",\n \"defaultAccount\": \"默认\",\n \"selected\": \"已选中\",\n \"removeAccount\": \"移除账号\",\n \"setAsDefault\": \"设为默认\",\n \"addAnotherAccount\": \"添加其他账号\",\n \"logoutAll\": \"注销所有账号\",\n \"retry\": \"重试\",\n \"copyCode\": \"复制代码\",\n \"fastMode\": \"FAST 模式\",\n \"fastModeDescription\": \"发送 service_tier=\\\"priority\\\" 换取更低延迟。默认关闭——开启后会按更高速率消耗 ChatGPT 配额。\"\n },\n \"endpointTest\": {\n \"title\": \"请求地址管理\",\n \"endpoints\": \"个端点\",\n \"autoSelect\": \"自动选择\",\n \"testSpeed\": \"测速\",\n \"testing\": \"测速中\",\n \"addEndpointPlaceholder\": \"https://api.example.com\",\n \"done\": \"完成\",\n \"noEndpoints\": \"暂无端点\",\n \"failed\": \"失败\",\n \"enterValidUrl\": \"请输入有效的 URL\",\n \"invalidUrlFormat\": \"URL 格式不正确\",\n \"onlyHttps\": \"仅支持 HTTP/HTTPS\",\n \"urlExists\": \"该地址已存在\",\n \"saveFailed\": \"保存失败,请重试\",\n \"loadEndpointsFailed\": \"加载自定义端点失败:\",\n \"addEndpointFailed\": \"添加自定义端点失败:\",\n \"removeEndpointFailed\": \"删除自定义端点失败:\",\n \"removeFailed\": \"删除失败: {{error}}\",\n \"updateLastUsedFailed\": \"更新端点使用时间失败\",\n \"pleaseAddEndpoint\": \"请先添加端点\",\n \"testUnavailable\": \"测速功能不可用\",\n \"noResult\": \"未返回结果\",\n \"testFailed\": \"测速失败: {{error}}\",\n \"empty\": \"暂无端点\"\n },\n \"providerAdvanced\": {\n \"pricingConfig\": \"计费配置\",\n \"useCustomPricing\": \"使用单独配置\",\n \"pricingConfigDesc\": \"为此供应商配置单独的计费参数,不启用时使用全局默认配置。\",\n \"costMultiplier\": \"成本倍率\",\n \"costMultiplierPlaceholder\": \"留空使用全局默认(1)\",\n \"costMultiplierHint\": \"实际成本 = 基础成本 × 倍率,支持小数如 1.5\",\n \"pricingModelSourceLabel\": \"计费模式\",\n \"pricingModelSourceInherit\": \"继承全局默认\",\n \"pricingModelSourceRequest\": \"请求模型\",\n \"pricingModelSourceResponse\": \"返回模型\",\n \"pricingModelSourceHint\": \"选择按请求模型还是返回模型进行定价匹配\"\n },\n \"grokConfig\": {\n \"authJson\": \"Grok API Key (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"xai-your-api-key\\\"\\n}\",\n \"authJsonHint\": \"供应商共享 API Key;每个 [model.*] 也可单独设置 api_key 或 env_key\",\n \"profileToml\": \"Grok Profile (TOML)\",\n \"profileTomlHint\": \"管理 endpoints、models、subagents 和 model.*;切换时保留其它 Grok 全局配置\",\n \"defaultModelPlaceholder\": \"例如: grok-4.5\",\n \"defaultModelHint\": \"Grok Build 默认使用的模型,可随时修改。留空时保留当前 Profile 的默认模型。\",\n \"advancedSectionHint\": \"包含 Grok 原生 API Backend、User-Agent 与本地代理请求覆盖。\",\n \"editGlobalConfig\": \"编辑 Grok 全局配置\",\n \"addToGlobalConfig\": \"加入全局配置\",\n \"addedToGlobalConfig\": \"当前 Grok Profile 已加入全局配置\",\n \"globalConfigTitle\": \"Grok Build 全局 config.toml\",\n \"globalConfigHint\": \"这里编辑本机实际配置。供应商切换只替换 endpoints、models、subagents 与 model.*,其它设置会保留;每次保存都会自动备份。\",\n \"globalConfigSaved\": \"Grok 全局配置已保存\",\n \"applyPrivacy\": \"应用隐私保护\",\n \"privacyApplied\": \"隐私保护配置已写入\"\n },\n \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 配置内容\",\n \"authJsonStorageHint\": \"代理接管中显示的是存储的 auth 配置,不是实时 auth.json\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 配置内容\",\n \"configTomlStorageHint\": \"代理接管中显示的是存储的 config 配置,不是实时 config.toml\",\n \"proxyTakeoverStorageNotice\": \"💡 当前应用处于代理接管状态。这里编辑的是存储的供应商配置,真实 live 配置已由代理临时接管\",\n \"writeCommonConfig\": \"应用通用配置\",\n \"editCommonConfig\": \"编辑通用配置\",\n \"editCommonConfigTitle\": \"编辑 Codex 通用配置片段\",\n \"commonConfigHint\": \"该片段会在勾选'应用通用配置'时追加到 config.toml 末尾\",\n \"enableGoalMode\": \"启用 Goal mode\",\n \"enableRemoteCompaction\": \"启用远程压缩\",\n \"remoteCompactionHint\": \"开启后会将当前 model_providers 条目的 name 写为 OpenAI,使 Codex 尝试使用远程压缩。\",\n \"apiUrlLabel\": \"API 请求地址\",\n \"extractFromCurrent\": \"从编辑内容提取\",\n \"extractNoCommonConfig\": \"当前编辑内容没有可提取的通用配置\",\n \"extractFailed\": \"提取失败: {{error}}\",\n \"saveFailed\": \"保存失败: {{error}}\",\n \"modelNameHint\": \"指定使用的模型,将自动更新到 config.toml 中\",\n \"modelName\": \"模型名称\",\n \"upstreamFormatLabel\": \"上游格式\",\n \"upstreamFormatHint\": \"供应商原生为 Responses API 就选 Responses(直连,不转换格式);使用 Chat Completions 协议就选 Chat;供应商只提供原生 Anthropic Messages 协议就选 Anthropic Messages。Chat 与 Anthropic Messages 均需开启路由接管才能转换为 Responses。\",\n \"upstreamFormatChat\": \"Chat Completions(需开启路由)\",\n \"upstreamFormatResponses\": \"Responses(原生)\",\n \"upstreamFormatAnthropic\": \"Anthropic Messages(需开启路由)\",\n \"anthropicAuthFieldLabel\": \"认证字段\",\n \"anthropicAuthFieldAuthToken\": \"ANTHROPIC_AUTH_TOKEN(Authorization)\",\n \"anthropicAuthFieldApiKey\": \"ANTHROPIC_API_KEY(x-api-key)\",\n \"anthropicAuthFieldHint\": \"选择网关接收 API Key 的请求头:ANTHROPIC_AUTH_TOKEN 发送 Authorization: Bearer;ANTHROPIC_API_KEY 发送 x-api-key。两者只发其一。\",\n \"impersonateClaudeCodeLabel\": \"模拟 Claude Code 客户端\",\n \"impersonateClaudeCodeHint\": \"网关或其上游限制只能通过 Claude Code 使用时开启:伪装 User-Agent、anthropic-beta、x-app 请求头,并在系统提示首行注入 Claude Code 身份。\",\n \"maxOutputTokensLabel\": \"最大输出 tokens\",\n \"maxOutputTokensPlaceholder\": \"留空则使用默认 8192\",\n \"maxOutputTokensHint\": \"Codex 不会把 model_max_output_tokens 写进请求体,默认上限 8192 容易在长回答或深度思考时被截断(stop_reason=max_tokens)。此处设置会作为 Anthropic 的 max_tokens 覆盖请求值。请勿超过该模型/网关的真实输出上限,否则可能 400。留空使用默认 8192。\",\n \"promptCacheRoutingLabel\": \"提示词缓存路由\",\n \"promptCacheRoutingAuto\": \"自动(推荐)\",\n \"promptCacheRoutingEnabled\": \"开启\",\n \"promptCacheRoutingDisabled\": \"关闭\",\n \"promptCacheRoutingHint\": \"自动模式仅对已确认兼容的上游发送 prompt_cache_key;开启可用于其他兼容网关,关闭可避免严格网关因未知字段返回 400。只使用客户端提供的稳定会话 ID。\",\n \"upstreamModelName\": \"上游模型名称\",\n \"upstreamModelNameHint\": \"Chat 格式下这里填写真实上游模型;路由会把 Codex 的 Responses 请求转换为 Chat Completions 并保持该模型。\",\n \"modelNamePlaceholder\": \"例如: gpt-5-codex\",\n \"contextWindow1M\": \"1M 上下文窗口\",\n \"contextWindow\": \"上下文窗口\",\n \"contextWindowPlaceholder\": \"例如: 128000\",\n \"autoCompactLimit\": \"压缩阈值\",\n \"autoCompactLimitPlaceholder\": \"例如: 90000\",\n \"autoCompactLimitHint\": \"上下文 token 数达到此阈值时自动压缩历史\",\n \"modelMetadataAdvanced\": \"模型元数据高级选项\",\n \"modelMetadataAdvancedHint\": \"用于未知模型的 Codex 元数据覆盖;清空字段会从 config.toml 删除对应配置。\",\n \"defaultModelLabel\": \"默认模型\",\n \"defaultModelPlaceholder\": \"例如: gpt-5.6\",\n \"defaultModelHint\": \"Codex 默认请求的模型,随时可改,无需等待预设更新。留空且配置了模型映射时,默认使用映射第一行。\",\n \"defaultModelNotInCatalog\": \"该模型不在模型映射中,Codex 的 /model 菜单不会列出它(直接请求仍然有效)。\",\n \"addToModelMapping\": \"加入映射\",\n \"modelMappingTitle\": \"模型映射\",\n \"modelMappingHint\": \"生成 Codex model_catalog_json,让 /model 命令显示这些第三方模型名;表中条目按填写内容原样保存。修改后需要重启 Codex 才能刷新模型列表。\",\n \"addCatalogModel\": \"添加模型\",\n \"catalogDisplayName\": \"显示名称\",\n \"catalogDisplayNamePlaceholder\": \"例如: DeepSeek V4 Flash\",\n \"catalogModelId\": \"模型 ID\",\n \"catalogModelPlaceholder\": \"例如: deepseek-v4-flash\",\n \"catalogColumnDisplay\": \"菜单显示名\",\n \"catalogColumnModel\": \"实际请求模型\",\n \"catalogColumnContext\": \"上下文窗口\",\n \"reasoningModeToggle\": \"支持思考模式\",\n \"reasoningModeHint\": \"上游 Chat Completions 接口支持开启或关闭 thinking 时启用。Kimi、GLM、Qwen 等通常属于这一类。\",\n \"reasoningEffortToggle\": \"支持思考等级\",\n \"reasoningEffortHint\": \"上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。\",\n \"reasoningGroupTitle\": \"思考能力\",\n \"reasoningSectionHint\": \"预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需手动覆盖。\",\n \"advancedSectionHint\": \"包含上游格式、模型映射、思考能力与自定义 User-Agent。使用 Chat Completions 协议的供应商需开启路由接管才能使用。\"\n },\n \"geminiConfig\": {\n \"envFile\": \"环境变量 (.env)\",\n \"envFileHint\": \"使用 .env 格式配置 Gemini 环境变量\",\n \"configJson\": \"配置文件 (config.json)\",\n \"configJsonHint\": \"使用 JSON 格式配置 Gemini 扩展参数(可选)\",\n \"writeCommonConfig\": \"应用通用配置\",\n \"editCommonConfig\": \"编辑通用配置\",\n \"editCommonConfigTitle\": \"编辑 Gemini 通用配置片段\",\n \"commonConfigHint\": \"该片段会在勾选\\\"应用通用配置\\\"时写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)\",\n \"extractFromCurrent\": \"从编辑内容提取\",\n \"extractNoCommonConfig\": \"当前编辑内容没有可提取的通用配置\",\n \"extractFailed\": \"提取失败: {{error}}\",\n \"saveFailed\": \"保存失败: {{error}}\",\n \"extractedConfigInvalid\": \"提取的配置格式错误\",\n \"invalidJsonFormat\": \"通用配置片段格式错误(必须是有效的 JSON)\",\n \"commonConfigInvalidKeys\": \"通用配置片段不能包含 GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_KEY(发现:{{keys}})\",\n \"commonConfigInvalidValues\": \"通用配置片段的值必须是字符串\",\n \"noCommonConfigToApply\": \"通用配置片段为空或没有可写入的内容\",\n \"configMergeFailed\": \"配置合并失败: {{error}}\",\n \"configReplaceFailed\": \"配置替换失败: {{error}}\"\n },\n \"opencode\": {\n \"npmPackage\": \"接口格式\",\n \"selectPackage\": \"选择接口格式\",\n \"npmPackageHint\": \"选择 AI 服务的 API 接口格式\",\n \"baseUrl\": \"Base URL\",\n \"baseUrlHint\": \"自定义 API 端点地址\",\n \"models\": \"模型配置\",\n \"modelsHint\": \"配置可用的模型及其显示名称\",\n \"addModel\": \"添加模型\",\n \"modelId\": \"模型 ID\",\n \"modelName\": \"显示名称\",\n \"noModels\": \"暂无模型配置\",\n \"modelsRequired\": \"请至少添加一个模型配置\",\n \"toggleModelDetails\": \"展开或收起模型详情\",\n \"providerKey\": \"供应商标识\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"配置文件中的唯一标识符,只能使用小写字母、数字和连字符\",\n \"providerKeyLockedHint\": \"该供应商已添加到应用配置中,供应商标识不可修改\",\n \"providerKeyRequired\": \"请填写供应商标识\",\n \"providerKeyDuplicate\": \"此标识已被使用,请更换\",\n \"providerKeyInvalid\": \"标识格式无效,只能使用小写字母、数字和连字符\",\n \"headers\": \"请求头\",\n \"headersHint\": \"随供应商请求发送的可选 HTTP 请求头,如 HTTP-Referer 或 X-Title。\",\n \"addHeader\": \"添加请求头\",\n \"headerName\": \"请求头\",\n \"headerValue\": \"值\",\n \"headerNamePlaceholder\": \"X-Title\",\n \"headerValuePlaceholder\": \"CC Switch\",\n \"removeHeader\": \"删除请求头\",\n \"noHeaders\": \"暂无自定义请求头\",\n \"extraOptions\": \"额外选项\",\n \"extraOptionsHint\": \"配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。\",\n \"addExtraOption\": \"添加\",\n \"extraOptionKey\": \"键名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暂无额外选项\",\n \"noModelOptions\": \"模型选项,点击 + 添加\",\n \"modelLimits\": \"Token 限制\",\n \"limitContext\": \"上下文\",\n \"limitOutput\": \"输出\",\n \"modelExtraFields\": \"模型属性\",\n \"noModelExtraFields\": \"模型属性 (variants, cost 等),点击 + 添加\",\n \"modelExtraFieldKeyPlaceholder\": \"variants\",\n \"sdkOptions\": \"SDK 选项\",\n \"modelOptionKeyPlaceholder\": \"provider\",\n \"modelOptionValuePlaceholder\": \"{\\\"order\\\": [\\\"baseten\\\"]}\"\n },\n \"providerPreset\": {\n \"label\": \"预设供应商\",\n \"custom\": \"自定义配置\",\n \"other\": \"其他\",\n \"hint\": \"选择预设后可继续调整下方字段。\",\n \"searchTooltip\": \"搜索预设\",\n \"searchAriaLabel\": \"搜索预设供应商\",\n \"searchPlaceholder\": \"搜索名称、官网或分类...\",\n \"sortAriaLabel\": \"切换预设排序\",\n \"sortNameAscTooltip\": \"按名称 A-Z 排序\",\n \"sortOriginalTooltip\": \"恢复原始顺序\",\n \"noSearchResults\": \"没有匹配的预设供应商。\"\n },\n \"usage\": {\n \"title\": \"使用统计\",\n \"subtitle\": \"查看 AI 模型的使用情况和成本统计\",\n \"today\": \"24小时\",\n \"last7days\": \"7天\",\n \"last30days\": \"30天\",\n \"presetToday\": \"当天\",\n \"preset1d\": \"1d\",\n \"preset7d\": \"7d\",\n \"preset14d\": \"14d\",\n \"preset30d\": \"30d\",\n \"totalRequests\": \"总请求数\",\n \"totalCost\": \"总成本\",\n \"cost\": \"成本\",\n \"perMillion\": \"(每百万)\",\n \"trends\": \"使用趋势\",\n \"rangeToday\": \"过去 24 小时 (按小时)\",\n \"rangeLast7Days\": \"过去 7 天\",\n \"rangeLast30Days\": \"过去 30 天\",\n \"totalTokens\": \"新生成 Token (Input+Output)\",\n \"cacheTokens\": \"缓存 Token\",\n \"realTotal\": \"真实消耗 Tokens\",\n \"realTotalHint\": \"Input + Output + 缓存写入 + 缓存命中\",\n \"freshInput\": \"新增输入\",\n \"cacheHitRate\": \"缓存命中率\",\n \"requestLogs\": \"请求日志\",\n \"providerStats\": \"Provider 统计\",\n \"modelStats\": \"模型统计\",\n \"time\": \"时间\",\n \"provider\": \"供应商\",\n \"billingModel\": \"计费模型\",\n \"inputTokens\": \"输入\",\n \"outputTokens\": \"输出\",\n \"cacheReadTokens\": \"缓存命中\",\n \"cacheCreationTokens\": \"缓存创建\",\n \"timingInfo\": \"用时/首字\",\n \"status\": \"状态\",\n \"multiplier\": \"倍率\",\n \"requestModel\": \"请求模型\",\n \"responseModel\": \"返回模型\",\n \"noData\": \"暂无数据\",\n \"unknownProvider\": \"未知供应商\",\n \"stream\": \"流\",\n \"nonStream\": \"非流\",\n \"source\": \"来源\",\n \"requestsLabel\": \"次请求\",\n \"costLabel\": \"总成本\",\n \"appFilter\": {\n \"all\": \"全部\",\n \"claude\": \"Claude Code\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\"\n },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"数据来源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"会话日志\",\n \"codex_db\": \"Codex 数据库\",\n \"codex_session\": \"Codex 会话日志\",\n \"gemini_session\": \"Gemini 会话日志\",\n \"opencode_session\": \"OpenCode 会话日志\"\n },\n \"sessionSync\": {\n \"trigger\": \"同步会话日志\",\n \"import\": \"导入会话\",\n \"resync\": \"同步\",\n \"imported\": \"从会话日志导入了 {{count}} 条记录\",\n \"upToDate\": \"会话日志已是最新\",\n \"failed\": \"会话同步失败\"\n },\n \"totalRecords\": \"共 {{total}} 条记录\",\n \"goToPage\": \"跳转\",\n \"pageInputPlaceholder\": \"页码\",\n \"modelPricing\": \"模型定价\",\n \"loadPricingError\": \"加载定价数据失败\",\n \"modelPricingDesc\": \"配置各模型的 Token 成本\",\n \"noPricingData\": \"暂无定价数据。点击\\\"新增\\\"添加模型定价配置。\",\n \"model\": \"模型\",\n \"pricingModel\": \"计价模型\",\n \"displayName\": \"显示名称\",\n \"inputCost\": \"输入成本\",\n \"outputCost\": \"输出成本\",\n \"cacheReadCost\": \"缓存命中\",\n \"cacheWriteCost\": \"缓存创建\",\n \"deleteConfirmTitle\": \"确认删除\",\n \"deleteConfirmDesc\": \"确定要删除此模型定价配置吗?此操作无法撤销。\",\n \"queryFailed\": \"查询失败\",\n \"refreshUsage\": \"刷新用量\",\n \"planUsage\": \"套餐用量\",\n \"invalid\": \"已失效\",\n \"total\": \"总:\",\n \"used\": \"已使用:\",\n \"remaining\": \"剩余:\",\n \"justNow\": \"刚刚\",\n \"minutesAgo\": \"{{count}} 分钟前\",\n \"hoursAgo\": \"{{count}} 小时前\",\n \"daysAgo\": \"{{count}} 天前\",\n \"multiplePlans\": \"{{count}} 个套餐\",\n \"expand\": \"展开\",\n \"collapse\": \"收起\",\n \"modelIdPlaceholder\": \"例如: claude-3-5-sonnet-20241022\",\n \"displayNamePlaceholder\": \"例如: Claude 3.5 Sonnet\",\n \"appType\": \"应用类型\",\n \"statusCode\": \"状态码\",\n \"allSources\": \"全部来源\",\n \"allModels\": \"全部模型\",\n \"filterBySource\": \"按来源筛选\",\n \"filterByModel\": \"按模型筛选\",\n \"refreshInterval\": \"自动刷新间隔\",\n \"refreshOff\": \"关闭\",\n \"timeRange\": \"时间范围\",\n \"customRange\": \"日历筛选\",\n \"customRangeHint\": \"支持日期与时间\",\n \"startTime\": \"开始时间\",\n \"endTime\": \"结束时间\",\n \"liveEndTime\": \"结束时间跟随当前时刻\",\n \"liveEndTimeNow\": \"现在\",\n \"input\": \"Input\",\n \"output\": \"Output\",\n \"cacheWrite\": \"创建\",\n \"cacheWriteNotReported\": \"OpenAI 协议不区分缓存写入,仅上报缓存命中\",\n \"cacheWritePartial\": \"部分协议(如 OpenAI)不上报缓存写入,数值可能偏低\",\n \"cacheRead\": \"命中\",\n \"baseCost\": \"基础\",\n \"costMultiplier\": \"成本倍率\",\n \"withMultiplier\": \"含倍率\",\n \"requestDetail\": \"请求详情\",\n \"requestNotFound\": \"请求未找到\",\n \"basicInfo\": \"基本信息\",\n \"tokenUsage\": \"Token 使用量\",\n \"cacheCreationCost\": \"缓存写入成本\",\n \"costBreakdown\": \"成本明细\",\n \"performance\": \"性能信息\",\n \"latency\": \"延迟\",\n \"errorMessage\": \"错误信息\",\n \"requests\": \"请求数\",\n \"tokens\": \"Tokens\",\n \"avgCost\": \"平均成本\",\n \"avgLatency\": \"平均延迟\",\n \"successRate\": \"成功率\",\n \"requestId\": \"请求 ID\",\n \"never\": \"从不\",\n \"modelId\": \"模型 ID\",\n \"modelIdRequired\": \"模型 ID 不能为空\",\n \"inputCostPerMillion\": \"输入成本 (每百万 tokens, USD)\",\n \"outputCostPerMillion\": \"输出成本 (每百万 tokens, USD)\",\n \"invalidPrice\": \"价格必须为非负数\",\n \"invalidTimeRange\": \"请选择完整的开始/结束时间\",\n \"invalidTimeRangeOrder\": \"开始时间不能晚于结束时间\",\n \"timeRangeTooLarge\": \"时间范围过大,请缩小范围\",\n \"addPricing\": \"新增定价\",\n \"editPricing\": \"编辑定价\",\n \"pricingAdded\": \"定价已添加\",\n \"pricingUpdated\": \"定价已更新\",\n \"importFromModelsDev\": \"从 models.dev 导入\",\n \"modelsDevHint\": \"无需手动填写,可从 models.dev 选择模型定价\",\n \"modelsDevPickerTitle\": \"从 models.dev 导入定价\",\n \"modelsDevPickerDesc\": \"选择要导入的模型(价格单位:USD / 百万 tokens),每次导入一个\",\n \"modelsDevSearchPlaceholder\": \"搜索模型或供应商(全量搜索)...\",\n \"modelsDevAllProviders\": \"全部供应商\",\n \"modelsDevLoadError\": \"加载 models.dev 数据失败\",\n \"modelsDevRetry\": \"重试\",\n \"modelsDevImportButton\": \"导入\",\n \"modelsDevImporting\": \"导入中...\",\n \"modelsDevImported\": \"已导入 {{name}} 的定价\",\n \"modelsDevNoResults\": \"没有匹配的模型\",\n \"modelsDevTruncated\": \"仅显示前 {{shown}} 条,共 {{total}} 条结果,请缩小搜索范围\",\n \"modelsDevDefaultHint\": \"默认展示最新发布的 {{shown}} 个模型(共 {{total}} 个),输入关键字可全量搜索\",\n \"cacheReadCostPerMillion\": \"缓存读取成本 (每百万 tokens, USD)\",\n \"cacheCreationCostPerMillion\": \"缓存写入成本 (每百万 tokens, USD)\"\n },\n \"usageScript\": {\n \"title\": \"配置用量查询\",\n \"enableUsageQuery\": \"启用用量查询\",\n \"presetTemplate\": \"预设模板\",\n \"requestUrl\": \"请求地址\",\n \"requestUrlPlaceholder\": \"例如:https://api.example.com\",\n \"method\": \"HTTP 方法\",\n \"templateCustom\": \"自定义\",\n \"templateGeneral\": \"通用模板\",\n \"templateNewAPI\": \"NewAPI\",\n \"templateCopilot\": \"GitHub Copilot\",\n \"templateTokenPlan\": \"Token Plan\",\n \"templateBalance\": \"官方\",\n \"templateOfficialSubscription\": \"官方订阅\",\n \"copilotAutoAuth\": \"自动使用 OAuth 认证,无需手动配置凭证\",\n \"tokenPlanHint\": \"自动使用供应商的 API Key 和 Base URL 查询 Token Plan 额度\",\n \"balanceHint\": \"自动使用供应商的 API Key 查询账户余额\",\n \"officialSubscriptionHint\": \"读取本机 CLI 的 OAuth 凭据,并调用官方接口查询订阅额度。默认关闭,只有启用后才会请求。\",\n \"resetDate\": \"重置日期\",\n \"premiumRequests\": \"Premium 请求\",\n \"credentialsConfig\": \"凭证配置\",\n \"credentialsHint\": \"留空则自动使用供应商配置\",\n \"optional\": \"可选\",\n \"apiKeyPlaceholder\": \"留空则使用供应商的 API Key\",\n \"baseUrlPlaceholder\": \"留空则使用供应商的请求地址\",\n \"baseUrl\": \"请求地址\",\n \"accessToken\": \"访问令牌(在个人安全设置里获取)\",\n \"accessTokenPlaceholder\": \"在'安全设置'里生成\",\n \"userId\": \"用户 ID\",\n \"userIdPlaceholder\": \"例如:114514\",\n \"accessKeyId\": \"AccessKey ID\",\n \"secretAccessKey\": \"SecretAccessKey\",\n \"volcengineAkSkHint\": \"火山用量查询需账号级 AccessKey ID / Secret(与推理 API Key 不同)。请在火山引擎控制台右上角账号菜单 →「API访问密钥」中创建。\",\n \"volcengineKeyConsoleLink\": \"密钥创建地址:\",\n \"zhipuTeamHint\": \"智谱团队套餐用量查询需团队 API Key(沿用本供应商凭据)+ 组织 ID + 项目 ID(作为请求头 bigmodel-organization / bigmodel-project)。组织与项目 ID 可在团队管理后台用量页的 URL 中找到。\",\n \"zhipuTeamConsoleLink\": \"团队用量页:\",\n \"organizationId\": \"组织 ID\",\n \"organizationIdPlaceholder\": \"例如:org-xxxxxx\",\n \"projectId\": \"项目 ID\",\n \"projectIdPlaceholder\": \"例如:proj-xxxxxx\",\n \"defaultPlan\": \"默认套餐\",\n \"queryFailedMessage\": \"查询失败\",\n \"queryScript\": \"查询脚本(JavaScript)\",\n \"timeoutSeconds\": \"超时时间(秒)\",\n \"headers\": \"请求头\",\n \"body\": \"请求 Body\",\n \"timeoutHint\": \"范围: 2-30 秒\",\n \"timeoutMustBeInteger\": \"超时时间必须为整数,小数部分已忽略\",\n \"timeoutCannotBeNegative\": \"超时时间不能为负数\",\n \"autoIntervalMinutes\": \"自动查询间隔(分钟,0 表示不自动查询)\",\n \"autoQueryInterval\": \"自动查询间隔(分钟)\",\n \"autoQueryIntervalHint\": \"0 表示不自动查询,建议 5-60 分钟\",\n \"intervalMustBeInteger\": \"自动查询间隔必须为整数,小数部分已忽略\",\n \"intervalCannotBeNegative\": \"自动查询间隔不能为负数\",\n \"intervalAdjusted\": \"自动查询间隔已调整为 {{value}} 分钟\",\n \"scriptHelp\": \"脚本编写说明:\",\n \"configFormat\": \"配置格式:\",\n \"commentOptional\": \"可选\",\n \"commentResponseIsJson\": \"response 是 API 返回的 JSON 数据\",\n \"extractorFormat\": \"extractor 返回格式(所有字段均为可选):\",\n \"tips\": \"💡 提示:\",\n \"testing\": \"测试中...\",\n \"testScript\": \"测试脚本\",\n \"format\": \"格式化\",\n \"saveConfig\": \"保存配置\",\n \"scriptEmpty\": \"脚本配置不能为空\",\n \"mustHaveReturn\": \"脚本必须包含 return 语句\",\n \"testSuccess\": \"测试成功!\",\n \"testFailed\": \"测试失败\",\n \"formatSuccess\": \"格式化成功\",\n \"formatFailed\": \"格式化失败\",\n \"supportedVariables\": \"支持的变量\",\n \"variablesHint\": \"支持变量: {{apiKey}}, {{baseUrl}} | extractor 函数接收 API 响应的 JSON 对象\",\n \"scriptConfig\": \"请求配置\",\n \"extractorCode\": \"提取器代码\",\n \"extractorHint\": \"返回对象需包含剩余额度等字段\",\n \"fieldIsValid\": \"• isValid: 布尔值,套餐是否有效\",\n \"fieldInvalidMessage\": \"• invalidMessage: 字符串,失效原因说明(当 isValid 为 false 时显示)\",\n \"fieldRemaining\": \"• remaining: 数字,剩余额度\",\n \"fieldUnit\": \"• unit: 字符串,单位(如 \\\"USD\\\")\",\n \"fieldPlanName\": \"• planName: 字符串,套餐名称\",\n \"fieldTotal\": \"• total: 数字,总额度\",\n \"fieldUsed\": \"• used: 数字,已用额度\",\n \"fieldExtra\": \"• extra: 字符串,扩展字段,可自由补充需要展示的文本\",\n \"tip1\": \"• 变量 {{apiKey}} 和 {{baseUrl}} 会自动替换\",\n \"tip2\": \"• extractor 函数在沙箱环境中执行,支持 ES2020+ 语法\",\n \"tip3\": \"• 整个配置必须用 () 包裹,形成对象字面量表达式\"\n },\n \"dbUpgrade\": {\n \"title\": \"数据库版本过新\",\n \"description\": \"当前数据库由更新版本的 CC Switch 创建,需要升级应用后才能继续使用。升级不会删除你的数据。\",\n \"versionInfo\": \"数据库版本 v{{db}} · 应用支持 v{{supported}}\",\n \"dbPath\": \"数据库文件\",\n \"checking\": \"正在检查可用更新…\",\n \"updateAvailable\": \"发现新版本 v{{version}},升级后即可继续使用。\",\n \"incompatibleTitle\": \"升级也无法解决\",\n \"incompatibleDescription\": \"你已是最新版本,但数据库版本(v{{db}})仍高于本应用支持的版本(v{{supported}})。该数据库可能由第三方客户端或更高版本创建,升级当前官方应用也无法兼容。\",\n \"preparing\": \"正在准备更新…\",\n \"downloading\": \"正在下载更新…\",\n \"upgradeNow\": \"升级应用\",\n \"retry\": \"重试升级\",\n \"openReleases\": \"打开发布页\",\n \"openConfigDir\": \"打开配置目录\",\n \"quit\": \"退出\"\n },\n \"errors\": {\n \"usage_query_failed\": \"用量查询失败\",\n \"configLoadFailedTitle\": \"配置加载失败\",\n \"configLoadFailedMessage\": \"无法读取配置文件:\\n{{path}}\\n\\n错误详情:\\n{{detail}}\\n\\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。\\n\\n应用将退出以便您进行修复。\"\n },\n \"presetSelector\": {\n \"title\": \"选择配置类型\",\n \"custom\": \"自定义\",\n \"customDescription\": \"手动配置供应商,需要填写完整的配置信息\",\n \"officialDescription\": \"官方登录,不需要填写 API Key\",\n \"presetDescription\": \"使用预设配置,只需填写 API Key\"\n },\n \"mcp\": {\n \"title\": \"MCP 管理\",\n \"import\": \"导入\",\n \"importExisting\": \"导入已有\",\n \"addMcp\": \"添加MCP\",\n \"claudeTitle\": \"Claude Code MCP 管理\",\n \"codexTitle\": \"Codex MCP 管理\",\n \"geminiTitle\": \"Gemini MCP 管理\",\n \"unifiedPanel\": {\n \"title\": \"MCP 服务器管理\",\n \"addServer\": \"添加服务器\",\n \"editServer\": \"编辑服务器\",\n \"deleteServer\": \"删除服务器\",\n \"deleteConfirm\": \"确定要删除服务器 \\\"{{id}}\\\" 吗?此操作无法撤销。\",\n \"noServers\": \"暂无服务器\",\n \"enabledApps\": \"启用的应用\",\n \"noImportFound\": \"未发现需要导入的 MCP 服务器。所有服务器已在 CC Switch 统一管理中。\",\n \"importSuccess\": \"成功导入 {{count}} 个 MCP 服务器\",\n \"apps\": {\n \"claude\": \"Claude\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\",\n \"openclaw\": \"OpenClaw\",\n \"hermes\": \"Hermes\"\n }\n },\n \"userLevelPath\": \"用户级 MCP 配置路径\",\n \"serverList\": \"服务器列表\",\n \"loading\": \"加载中...\",\n \"empty\": \"暂无 MCP 服务器\",\n \"emptyDescription\": \"点击右上角按钮添加第一个 MCP 服务器\",\n \"add\": \"添加 MCP\",\n \"addServer\": \"新增 MCP\",\n \"editServer\": \"编辑 MCP\",\n \"addClaudeServer\": \"新增 Claude Code MCP\",\n \"editClaudeServer\": \"编辑 Claude Code MCP\",\n \"addCodexServer\": \"新增 Codex MCP\",\n \"editCodexServer\": \"编辑 Codex MCP\",\n \"configPath\": \"配置路径\",\n \"serverCount\": \"已配置 {{count}} 个 MCP 服务器\",\n \"enabledCount\": \"已启用 {{count}} 个\",\n \"template\": {\n \"fetch\": \"快速模板:mcp-fetch\"\n },\n \"form\": {\n \"title\": \"MCP 标题(唯一)\",\n \"titlePlaceholder\": \"my-mcp-server\",\n \"name\": \"显示名称\",\n \"namePlaceholder\": \"例如 @modelcontextprotocol/server-time\",\n \"enabledApps\": \"启用到应用\",\n \"noAppsWarning\": \"至少选择一个应用\",\n \"description\": \"描述\",\n \"descriptionPlaceholder\": \"可选的描述信息\",\n \"tags\": \"标签(逗号分隔)\",\n \"tagsPlaceholder\": \"stdio, time, utility\",\n \"homepage\": \"主页链接\",\n \"homepagePlaceholder\": \"https://example.com\",\n \"docs\": \"文档链接\",\n \"docsPlaceholder\": \"https://example.com/docs\",\n \"additionalInfo\": \"附加信息\",\n \"jsonConfig\": \"完整的 JSON 配置\",\n \"jsonConfigOrPrefix\": \"完整的 JSON 配置或者使用\",\n \"tomlConfigOrPrefix\": \"完整的 TOML 配置或者使用\",\n \"jsonPlaceholder\": \"{\\n \\\"type\\\": \\\"stdio\\\",\\n \\\"command\\\": \\\"uvx\\\",\\n \\\"args\\\": [\\\"mcp-server-fetch\\\"]\\n}\",\n \"tomlConfig\": \"完整的 TOML 配置\",\n \"tomlPlaceholder\": \"type = \\\"stdio\\\"\\ncommand = \\\"uvx\\\"\\nargs = [\\\"mcp-server-fetch\\\"]\",\n \"useWizard\": \"配置向导\",\n \"syncOtherSide\": \"同步到 {{target}}\",\n \"syncOtherSideHint\": \"勾选后会把当前配置同时写入 {{target}},若存在同名配置将被覆盖\",\n \"willOverwriteWarning\": \"将覆盖 {{target}} 中的同名配置\"\n },\n \"wizard\": {\n \"title\": \"MCP 配置向导\",\n \"hint\": \"快速配置 MCP 服务器,自动生成 JSON 配置\",\n \"type\": \"类型\",\n \"typeStdio\": \"stdio\",\n \"typeHttp\": \"http\",\n \"typeSse\": \"sse\",\n \"command\": \"命令\",\n \"commandPlaceholder\": \"npx 或 uvx\",\n \"args\": \"参数\",\n \"argsPlaceholder\": \"arg1\\narg2\",\n \"env\": \"环境变量\",\n \"envPlaceholder\": \"KEY1=value1\\nKEY2=value2\",\n \"url\": \"URL\",\n \"urlPlaceholder\": \"https://api.example.com/mcp\",\n \"urlRequired\": \"请输入 URL\",\n \"headers\": \"请求头(可选)\",\n \"headersPlaceholder\": \"Authorization: Bearer your_token_here\\nContent-Type: application/json\",\n \"preview\": \"配置预览\",\n \"apply\": \"应用配置\"\n },\n \"id\": \"标识 (唯一)\",\n \"type\": \"类型\",\n \"command\": \"命令\",\n \"validateCommand\": \"校验命令\",\n \"args\": \"参数\",\n \"argsPlaceholder\": \"例如:mcp-server-fetch --help\",\n \"env\": \"环境变量 (一行一个,KEY=VALUE)\",\n \"envPlaceholder\": \"FOO=bar\\nHELLO=world\",\n \"reset\": \"重置\",\n \"msg\": {\n \"saved\": \"已保存\",\n \"deleted\": \"已删除\",\n \"enabled\": \"已启用\",\n \"disabled\": \"已禁用\",\n \"templateAdded\": \"已添加模板\"\n },\n \"error\": {\n \"idRequired\": \"请填写标识\",\n \"idExists\": \"该标识已存在,请更换\",\n \"jsonInvalid\": \"JSON 格式错误,请检查\",\n \"tomlInvalid\": \"TOML 格式错误,请检查\",\n \"commandRequired\": \"请填写命令\",\n \"singleServerObjectRequired\": \"此处只需单个服务器对象,请不要粘贴包含 mcpServers 的整份配置\",\n \"saveFailed\": \"保存失败\",\n \"deleteFailed\": \"删除失败\"\n },\n \"validation\": {\n \"ok\": \"命令可用\",\n \"fail\": \"命令不可用\"\n },\n \"confirm\": {\n \"deleteTitle\": \"删除 MCP 服务器\",\n \"deleteMessage\": \"确定要删除 MCP 服务器 \\\"{{id}}\\\" 吗?此操作无法撤销。\"\n },\n \"presets\": {\n \"title\": \"选择 MCP 类型\",\n \"enable\": \"启用\",\n \"enabled\": \"已启用\",\n \"installed\": \"已安装\",\n \"docs\": \"文档\",\n \"requiresEnv\": \"需要环境变量\",\n \"fetch\": {\n \"name\": \"mcp-server-fetch\",\n \"description\": \"通用 HTTP 请求工具,支持 GET/POST 等 HTTP 方法,适合快速请求接口/抓取网页数据\"\n },\n \"time\": {\n \"name\": \"@modelcontextprotocol/server-time\",\n \"description\": \"时间查询工具,提供当前时间、时区转换、日期计算等功能\"\n },\n \"memory\": {\n \"name\": \"@modelcontextprotocol/server-memory\",\n \"description\": \"知识图谱记忆系统,支持存储实体、关系和观察,让 AI 记住对话中的重要信息\"\n },\n \"sequential-thinking\": {\n \"name\": \"@modelcontextprotocol/server-sequential-thinking\",\n \"description\": \"顺序思考工具,帮助 AI 将复杂问题分解为多个步骤,逐步深入思考\"\n },\n \"context7\": {\n \"name\": \"@upstash/context7-mcp\",\n \"description\": \"Context7 文档搜索工具,提供最新的库文档和代码示例,配置 key 会有更高限额\"\n }\n }\n },\n \"prompts\": {\n \"manage\": \"提示词\",\n \"title\": \"{{appName}} 提示词管理\",\n \"claudeTitle\": \"Claude 提示词管理\",\n \"codexTitle\": \"Codex 提示词管理\",\n \"add\": \"添加提示词\",\n \"edit\": \"编辑提示词\",\n \"addTitle\": \"添加 {{appName}} 提示词\",\n \"editTitle\": \"编辑 {{appName}} 提示词\",\n \"import\": \"导入现有\",\n \"count\": \"共 {{count}} 个提示词\",\n \"enabled\": \"已启用\",\n \"enable\": \"启用\",\n \"enabledName\": \"已启用: {{name}}\",\n \"noneEnabled\": \"未启用任何提示词\",\n \"currentFile\": \"当前 {{filename}} 内容\",\n \"empty\": \"暂无提示词\",\n \"emptyDescription\": \"点击右上角按钮添加或导入提示词\",\n \"loading\": \"加载中...\",\n \"name\": \"名称\",\n \"namePlaceholder\": \"例如:项目默认提示词\",\n \"description\": \"描述\",\n \"descriptionPlaceholder\": \"可选的描述信息\",\n \"content\": \"内容\",\n \"contentPlaceholder\": \"# {{filename}}\\n\\n在此输入提示词内容...\",\n \"loadFailed\": \"加载提示词失败\",\n \"saveSuccess\": \"保存成功\",\n \"saveFailed\": \"保存失败\",\n \"deleteSuccess\": \"删除成功\",\n \"deleteFailed\": \"删除失败\",\n \"enableSuccess\": \"启用成功\",\n \"enableFailed\": \"启用失败\",\n \"disableSuccess\": \"禁用成功\",\n \"disableFailed\": \"禁用失败\",\n \"importSuccess\": \"导入成功\",\n \"importFailed\": \"导入失败\",\n \"confirm\": {\n \"deleteTitle\": \"确认删除\",\n \"deleteMessage\": \"确定要删除提示词 \\\"{{name}}\\\" 吗?\"\n }\n },\n \"profiles\": {\n \"switcherTooltip\": {\n \"claude\": \"项目:一键切换 Claude Code 的供应商 / MCP / Skills / 记忆文件\",\n \"claude-desktop\": \"项目:一键切换 Claude Desktop 的供应商\",\n \"codex\": \"项目:一键切换 Codex 的供应商 / MCP / Skills / 记忆文件\"\n },\n \"none\": \"不使用项目\",\n \"searchPlaceholder\": \"搜索项目\",\n \"empty\": \"暂无项目\",\n \"createFromCurrent\": \"新建项目\",\n \"createDescription\": {\n \"claude\": \"把 Claude Code 当前的供应商、MCP、Skills、记忆文件保存为一个项目,之后可一键切换。\",\n \"claude-desktop\": \"把 Claude Desktop 当前的供应商保存为一个项目,之后可一键切换。\",\n \"codex\": \"把 Codex 当前的供应商、MCP、Skills 和记忆文件配置保存为一个项目,之后可一键切换。\"\n },\n \"manage\": \"管理项目…\",\n \"manageTitle\": \"管理项目\",\n \"manageDescription\": \"项目是配置快照。切换项目时,当前配置会自动保存回原项目。\",\n \"namePlaceholder\": \"例如:开发、绘图\",\n \"rename\": \"重命名\",\n \"noSnapshotForScope\": \"本侧未保存\",\n \"delete\": \"删除\",\n \"deleteConfirmTitle\": \"确认删除\",\n \"deleteConfirmMessage\": \"确定要删除项目 \\\"{{name}}\\\" 吗?不会改动任何现有配置。\",\n \"createSuccess\": \"项目已创建\",\n \"createFailed\": \"创建项目失败: {{detail}}\",\n \"updateSuccess\": \"项目已更新\",\n \"updateFailed\": \"更新项目失败: {{detail}}\",\n \"deleteSuccess\": \"项目已删除\",\n \"deleteFailed\": \"删除项目失败: {{detail}}\",\n \"applySuccess\": \"项目已应用\",\n \"applyFailed\": \"应用项目失败: {{detail}}\",\n \"applyWarnings\": \"项目已应用,{{warningCount}} 条警告:\\n{{details}}\",\n \"clearSuccess\": \"已切换为不使用项目\"\n },\n \"workspace\": {\n \"title\": \"Workspace 文件管理\",\n \"manage\": \"Workspace\",\n \"files\": {\n \"agents\": \"Agent 操作指令和规则\",\n \"soul\": \"Agent 人格和沟通风格\",\n \"user\": \"用户档案和偏好\",\n \"identity\": \"Agent 名称和头像\",\n \"tools\": \"本地工具文档\",\n \"memory\": \"长期记忆和决策记录\",\n \"heartbeat\": \"心跳运行清单\",\n \"bootstrap\": \"首次运行仪式\",\n \"boot\": \"网关重启清单\"\n },\n \"editing\": \"编辑 {{filename}}\",\n \"saveSuccess\": \"保存成功\",\n \"saveFailed\": \"保存失败\",\n \"loadFailed\": \"读取失败\",\n \"openDirectory\": \"在文件管理器中打开\",\n \"dailyMemory\": {\n \"title\": \"每日记忆\",\n \"sectionTitle\": \"每日记忆\",\n \"cardTitle\": \"每日记忆文件\",\n \"cardDescription\": \"浏览管理每日记忆\",\n \"createToday\": \"添加记忆\",\n \"empty\": \"暂无每日记忆文件\",\n \"loadFailed\": \"加载每日记忆文件失败\",\n \"createFailed\": \"创建每日记忆文件失败\",\n \"deleteSuccess\": \"每日记忆文件已删除\",\n \"deleteFailed\": \"删除每日记忆文件失败\",\n \"confirmDeleteTitle\": \"删除每日记忆\",\n \"confirmDeleteMessage\": \"确定删除 {{date}} 的每日记忆吗?此操作不可撤销。\",\n \"searchPlaceholder\": \"搜索全文内容...\",\n \"searchScopeHint\": \"全文搜索所有每日记忆 ⌘F\",\n \"searchCloseHint\": \"Esc 关闭\",\n \"noSearchResults\": \"没有找到匹配的每日记忆。\",\n \"searching\": \"搜索中...\",\n \"searchFailed\": \"搜索失败\",\n \"matchCount\": \"{{count}} 处匹配\"\n }\n },\n \"openclaw\": {\n \"backupCreated\": \"已创建备份:{{path}}\",\n \"providerKey\": \"供应商标识\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"配置文件中的唯一标识符,只能使用小写字母、数字和连字符\",\n \"providerKeyLockedHint\": \"该供应商已添加到应用配置中,供应商标识不可修改\",\n \"providerKeyRequired\": \"请填写供应商标识\",\n \"providerKeyDuplicate\": \"此标识已被使用,请更换\",\n \"providerKeyInvalid\": \"标识格式无效,只能使用小写字母、数字和连字符\",\n \"apiProtocol\": \"API 协议\",\n \"selectProtocol\": \"选择 API 协议\",\n \"apiProtocolHint\": \"选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。\",\n \"baseUrl\": \"API 端点\",\n \"baseUrlHint\": \"供应商的 API 端点地址。\",\n \"models\": \"模型列表\",\n \"addModel\": \"添加模型\",\n \"noModels\": \"暂无模型配置。点击添加模型来配置可用模型。\",\n \"modelId\": \"模型 ID\",\n \"modelIdPlaceholder\": \"claude-3-sonnet\",\n \"modelName\": \"显示名称\",\n \"modelNamePlaceholder\": \"Claude 3 Sonnet\",\n \"contextWindow\": \"上下文窗口\",\n \"maxTokens\": \"最大输出 Tokens\",\n \"reasoning\": \"推理模式\",\n \"reasoningOn\": \"启用\",\n \"reasoningOff\": \"关闭\",\n \"inputTypes\": \"输入类型\",\n \"inputCost\": \"输入价格 ($/M tokens)\",\n \"outputCost\": \"输出价格 ($/M tokens)\",\n \"advancedOptions\": \"高级选项\",\n \"cacheReadCost\": \"缓存读取价格 ($/M tokens)\",\n \"cacheWriteCost\": \"缓存写入价格 ($/M tokens)\",\n \"cacheCostHint\": \"缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。\",\n \"modelsHint\": \"配置该供应商支持的模型。模型 ID 用于 API 调用,显示名称用于界面展示。\",\n \"userAgent\": \"发送 User-Agent\",\n \"userAgentHint\": \"部分供应商需要浏览器 User-Agent 才能正常访问。\",\n \"env\": {\n \"title\": \"环境变量\",\n \"description\": \"管理 openclaw.json 中的环境变量配置(API Key、自定义变量等)\",\n \"editorHint\": \"以 JSON 形式编辑整个 env 节点。支持 env.vars、env.shellEnv 等嵌套对象。\",\n \"objectRequired\": \"OpenClaw 的 env 必须是 JSON 对象。\",\n \"invalidJson\": \"OpenClaw 的 env 必须是合法 JSON。\",\n \"empty\": \"OpenClaw 的 env 不能为空。空对象请使用 {}。\",\n \"keyPlaceholder\": \"变量名\",\n \"valuePlaceholder\": \"值\",\n \"add\": \"添加变量\",\n \"saveSuccess\": \"环境变量已保存\",\n \"saveFailed\": \"保存环境变量失败\",\n \"loadFailed\": \"读取环境变量失败\",\n \"duplicateKey\": \"检测到重复的变量名: {{key}}\"\n },\n \"tools\": {\n \"title\": \"工具权限\",\n \"description\": \"管理 openclaw.json 中的工具权限配置(允许/拒绝列表)\",\n \"profile\": \"权限模式\",\n \"profileMinimal\": \"最小权限\",\n \"profileCoding\": \"编码\",\n \"profileMessaging\": \"对话\",\n \"profileFull\": \"完全访问\",\n \"profileUnset\": \"未设置\",\n \"unsupportedProfileTitle\": \"检测到不受支持的工具配置\",\n \"unsupportedProfileDescription\": \"当前 tools.profile 的值“{{value}}”不在 OpenClaw 支持列表内。在你手动选择新值之前,它会被保留。\",\n \"unsupportedProfileLabel\": \"不受支持\",\n \"allowList\": \"允许列表\",\n \"denyList\": \"拒绝列表\",\n \"patternPlaceholder\": \"工具名称或模式\",\n \"addAllow\": \"添加允许\",\n \"addDeny\": \"添加拒绝\",\n \"saveSuccess\": \"工具权限已保存\",\n \"saveFailed\": \"保存工具权限失败\",\n \"loadFailed\": \"读取工具权限失败\"\n },\n \"agents\": {\n \"title\": \"Agents 配置\",\n \"description\": \"管理 openclaw.json 中的 agents.defaults 配置(默认模型、运行参数等)\",\n \"modelSection\": \"模型配置\",\n \"primaryModel\": \"默认模型\",\n \"primaryModelHint\": \"从已配置供应商的模型中选择默认模型\",\n \"notSet\": \"未设置\",\n \"fallbackModels\": \"回退模型\",\n \"fallbackModelsHint\": \"当主模型不可用时,按优先级依次尝试以下回退模型\",\n \"addFallback\": \"添加回退模型\",\n \"noModels\": \"暂无已配置的供应商模型。请先添加 OpenClaw 供应商。\",\n \"notInList\": \"{{value}} (供应商未配置)\",\n \"runtimeSection\": \"运行参数\",\n \"workspace\": \"工作区路径\",\n \"timeout\": \"超时时间(秒)\",\n \"contextTokens\": \"上下文 Token 数\",\n \"maxConcurrent\": \"最大并发数\",\n \"legacyTimeoutTitle\": \"检测到旧版超时字段\",\n \"legacyTimeoutDescription\": \"当前配置仍在使用 agents.defaults.timeout。保存本页面时会迁移为 timeoutSeconds。\",\n \"saveSuccess\": \"Agents 配置已保存\",\n \"saveFailed\": \"保存 Agents 配置失败\",\n \"loadFailed\": \"读取 Agents 配置失败\"\n },\n \"health\": {\n \"title\": \"检测到 OpenClaw 配置警告\",\n \"invalidToolsProfile\": \"tools.profile 使用了不受支持的值。OpenClaw 当前只支持 minimal、coding、messaging、full。\",\n \"legacyTimeout\": \"agents.defaults.timeout 已废弃。打开并保存 Agents 面板即可迁移到 timeoutSeconds。\",\n \"stringifiedEnvVars\": \"env.vars 应为对象,但当前值看起来像被字符串化或已损坏。\",\n \"stringifiedShellEnv\": \"env.shellEnv 应为对象,但当前值看起来像被字符串化或已损坏。\",\n \"parseFailed\": \"openclaw.json 不是合法 JSON5。请先修复文件,再通过这里编辑。\"\n },\n \"primaryModel\": \"默认模型\",\n \"fallbackModel\": \"回退模型\"\n },\n \"hermes\": {\n \"form\": {\n \"baseUrl\": \"API 端点\",\n \"baseUrlHint\": \"供应商的 API 端点地址。\",\n \"providerKey\": \"供应商标识\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"只能使用小写字母、数字和连字符。用作 config.yaml 中的供应商名称。\",\n \"providerKeyLockedHint\": \"该供应商已添加到 Hermes 配置中,标识不可修改。\",\n \"providerKeyRequired\": \"供应商标识不能为空\",\n \"providerKeyInvalid\": \"供应商标识只能包含小写字母、数字和连字符\",\n \"providerKeyDuplicate\": \"该供应商标识已存在\",\n \"apiMode\": \"API 模式\",\n \"apiModeHint\": \"供应商 API 协议。请根据端点选择正确的协议。\",\n \"apiModeChatCompletions\": \"OpenAI Chat Completions\",\n \"apiModeAnthropicMessages\": \"Anthropic Messages\",\n \"apiModeCodexResponses\": \"OpenAI Responses\",\n \"apiModeBedrockConverse\": \"AWS Bedrock Converse\",\n \"baseUrlRequired\": \"API 端点不能为空\",\n \"baseUrlScheme\": \"请使用 http:// 或 https:// 开头的地址\",\n \"baseUrlInvalid\": \"API 端点不是有效的 URL\",\n \"models\": \"模型列表\",\n \"addModel\": \"添加模型\",\n \"noModels\": \"暂无模型配置。切换到此供应商时将不会更新默认模型。\",\n \"modelId\": \"模型 ID\",\n \"modelIdPlaceholder\": \"anthropic/claude-opus-4-8\",\n \"modelName\": \"显示名称\",\n \"modelNamePlaceholder\": \"Claude Opus 4.8\",\n \"contextLength\": \"上下文长度\",\n \"advancedOptions\": \"高级选项\",\n \"modelsHint\": \"切换到此供应商时,第一个模型会写入顶层 model.default。\",\n \"primaryModel\": \"默认模型\",\n \"fallbackModel\": \"备选模型\",\n \"providerAdvanced\": \"供应商高级选项\",\n \"rateLimitDelay\": \"请求间隔(秒)\",\n \"rateLimitDelayHint\": \"连续请求间的最小间隔秒数(可选)。留空表示无限制。\"\n },\n \"webui\": {\n \"open\": \"打开 Hermes Web UI\",\n \"offline\": \"Hermes Web UI 未启动,请先运行 `hermes dashboard` 启动服务。\",\n \"openFailed\": \"打开 Hermes Web UI 失败\",\n \"launchConfirmTitle\": \"Hermes Dashboard 未启动\",\n \"launchConfirmMessage\": \"是否打开终端并运行 `hermes dashboard` 启动服务?\\n\\n启动完成后会自动打开浏览器。\\n\\n若终端提示找不到 hermes 或缺少 web 依赖,请先运行:\\npip install hermes-agent[web]\",\n \"launchConfirmAction\": \"打开终端并启动\",\n \"launching\": \"已在终端启动 hermes dashboard\",\n \"launchFailed\": \"打开终端失败\"\n },\n \"memory\": {\n \"title\": \"记忆管理\",\n \"agentTab\": \"Agent 记忆 (MEMORY.md)\",\n \"userTab\": \"用户画像 (USER.md)\",\n \"usage\": \"已用 {{current}} / {{limit}} 字符\",\n \"overLimit\": \"已超过上限,Hermes 下次加载时会截断\",\n \"enableOn\": \"已启用\",\n \"enableOff\": \"已禁用\",\n \"disabledHint\": \"在重新启用前 Hermes 会跳过此记忆\",\n \"toggleFailed\": \"切换记忆状态失败\",\n \"saveSuccess\": \"记忆已保存\",\n \"saveFailed\": \"保存记忆失败\",\n \"loadFailed\": \"读取记忆文件失败\",\n \"openConfig\": \"在 Hermes Web UI 调整上限\",\n \"runtimeNote\": \"更改将在 Hermes 下次启动或新建会话时生效。\"\n }\n },\n \"env\": {\n \"warning\": {\n \"title\": \"检测到系统环境变量冲突\",\n \"description\": \"发现 {{count}} 个环境变量可能会覆盖您的配置\"\n },\n \"actions\": {\n \"expand\": \"查看详情\",\n \"collapse\": \"收起\",\n \"selectAll\": \"全选\",\n \"clearSelection\": \"取消选择\",\n \"deleteSelected\": \"删除选中 ({{count}})\",\n \"deleting\": \"删除中...\"\n },\n \"field\": {\n \"value\": \"值\",\n \"source\": \"来源\"\n },\n \"source\": {\n \"userRegistry\": \"用户环境变量 (注册表)\",\n \"systemRegistry\": \"系统环境变量 (注册表)\",\n \"systemEnv\": \"系统环境变量\"\n },\n \"delete\": {\n \"success\": \"环境变量已成功删除\",\n \"error\": \"删除环境变量失败\"\n },\n \"backup\": {\n \"location\": \"备份位置: {{path}}\"\n },\n \"confirm\": {\n \"title\": \"确认删除环境变量\",\n \"message\": \"确定要删除 {{count}} 个环境变量吗?\",\n \"backupNotice\": \"删除前将自动备份,您可以稍后恢复。删除后需要重启应用或终端才能生效。\",\n \"confirm\": \"确认删除\"\n },\n \"error\": {\n \"noSelection\": \"请选择要删除的环境变量\"\n }\n },\n \"skills\": {\n \"manage\": \"Skills\",\n \"title\": \"Skills 管理\",\n \"description\": \"从流行的仓库发现并安装技能,扩展 Claude Code/Codex/Gemini 的能力\",\n \"refresh\": \"刷新\",\n \"refreshing\": \"刷新中...\",\n \"repoManager\": \"仓库管理\",\n \"count\": \"共 {{count}} 个技能\",\n \"empty\": \"暂无可用技能\",\n \"emptyDescription\": \"添加技能仓库以发现可用的技能\",\n \"addRepo\": \"添加技能仓库\",\n \"loading\": \"加载中...\",\n \"installed\": \"已安装\",\n \"install\": \"安装\",\n \"installing\": \"安装中...\",\n \"uninstall\": \"卸载\",\n \"uninstalling\": \"卸载中...\",\n \"view\": \"查看\",\n \"noDescription\": \"暂无描述\",\n \"loadFailed\": \"加载失败\",\n \"installSuccess\": \"技能 {{name}} 已安装\",\n \"installFailed\": \"安装失败\",\n \"uninstallSuccess\": \"技能 {{name}} 已卸载\",\n \"uninstallFailed\": \"卸载失败\",\n \"update\": \"更新\",\n \"updating\": \"更新中...\",\n \"updateAvailable\": \"可更新\",\n \"updateSuccess\": \"技能 {{name}} 已更新到最新版本\",\n \"updateFailed\": \"更新失败\",\n \"checkUpdates\": \"检查更新\",\n \"checkingUpdates\": \"检查中...\",\n \"noUpdates\": \"所有技能已是最新版本\",\n \"updatesFound\": \"发现 {{count}} 个技能有可用更新\",\n \"updateAll\": \"全部更新 ({{count}})\",\n \"updatingAll\": \"更新中...\",\n \"updateAllSuccess\": \"已成功更新 {{count}} 个技能\",\n \"error\": {\n \"skillNotFound\": \"技能不存在:{{directory}}\",\n \"missingRepoInfo\": \"缺少仓库信息(owner 或 name)\",\n \"downloadTimeout\": \"下载仓库 {{owner}}/{{name}} 超时({{timeout}}秒)\",\n \"downloadTimeoutHint\": \"请检查网络连接或稍后重试\",\n \"skillPathNotFound\": \"仓库 {{owner}}/{{name}} 中未找到技能路径 '{{path}}'\",\n \"skillDirNotFound\": \"技能目录不存在:{{path}}\",\n \"directoryConflict\": \"技能目录 '{{directory}}' 已被 {{existing_repo}} 占用,无法从 {{new_repo}} 安装\",\n \"emptyArchive\": \"下载的压缩包为空\",\n \"downloadFailed\": \"下载失败:HTTP {{status}}\",\n \"allBranchesFailed\": \"所有分支下载失败,尝试了:{{branches}}\",\n \"httpError\": \"HTTP 错误 {{status}}\",\n \"http403\": \"GitHub 访问受限,可能是请求频率过高\",\n \"http404\": \"仓库或分支不存在,请检查地址\",\n \"http429\": \"请求过于频繁,请等待后重试\",\n \"parseMetadataFailed\": \"解析技能元数据失败\",\n \"getHomeDirFailed\": \"无法获取用户主目录\",\n \"noSkillsInZip\": \"ZIP 文件中未找到技能(需包含 SKILL.md 文件)\",\n \"networkError\": \"网络错误\",\n \"fsError\": \"文件系统错误\",\n \"unknownError\": \"未知错误\",\n \"suggestion\": {\n \"checkNetwork\": \"请检查网络连接\",\n \"checkProxy\": \"建议配置 HTTP 代理\",\n \"retryLater\": \"请稍后重试\",\n \"checkRepoUrl\": \"请检查仓库地址和分支名称\",\n \"checkDiskSpace\": \"请检查磁盘空间\",\n \"checkPermission\": \"请检查目录权限\",\n \"uninstallFirst\": \"请先卸载已安装的同名技能\",\n \"checkZipContent\": \"请确认 ZIP 文件包含有效的技能目录(含 SKILL.md 文件)\"\n }\n },\n \"repo\": {\n \"title\": \"管理技能仓库\",\n \"description\": \"添加或删除 GitHub 技能仓库源\",\n \"url\": \"仓库 URL\",\n \"urlPlaceholder\": \"owner/name 或 https://github.com/owner/name\",\n \"branch\": \"分支\",\n \"branchPlaceholder\": \"main\",\n \"path\": \"技能路径\",\n \"pathPlaceholder\": \"skills (可选,留空扫描根目录)\",\n \"add\": \"添加仓库\",\n \"list\": \"已添加的仓库\",\n \"empty\": \"暂无仓库\",\n \"invalidUrl\": \"无效的仓库 URL 格式\",\n \"addSuccess\": \"仓库 {{owner}}/{{name}} 已添加,识别到 {{count}} 个技能\",\n \"addFailed\": \"添加失败\",\n \"removeSuccess\": \"仓库 {{owner}}/{{name}} 已删除\",\n \"removeFailed\": \"删除失败\",\n \"skillCount\": \"识别到 {{count}} 个技能\"\n },\n \"search\": \"搜索技能\",\n \"searchPlaceholder\": \"搜索技能名称或仓库名称...\",\n \"searchSource\": {\n \"repos\": \"仓库\",\n \"skillssh\": \"skills.sh\"\n },\n \"skillssh\": {\n \"searchPlaceholder\": \"搜索 skills.sh(至少 2 个字符)...\",\n \"installs\": \"{{count}} 次安装\",\n \"loadMore\": \"加载更多\",\n \"loading\": \"正在搜索 skills.sh...\",\n \"noResults\": \"未找到 \\\"{{query}}\\\" 相关技能\",\n \"error\": \"搜索 skills.sh 失败\",\n \"poweredBy\": \"由 skills.sh 提供\"\n },\n \"filter\": {\n \"placeholder\": \"状态筛选\",\n \"all\": \"全部\",\n \"installed\": \"已安装\",\n \"uninstalled\": \"未安装\",\n \"repo\": \"仓库筛选\",\n \"allRepos\": \"全部仓库\"\n },\n \"noResults\": \"未找到匹配的技能\",\n \"noInstalled\": \"暂无已安装的技能\",\n \"noInstalledDescription\": \"从仓库发现并安装技能,或导入已有的技能\",\n \"discover\": \"发现技能\",\n \"import\": \"导入已有\",\n \"importDescription\": \"选择要导入到 CC Switch 统一管理的技能\",\n \"importSuccess\": \"成功导入 {{count}} 个技能\",\n \"importSelected\": \"导入已选 ({{count}})\",\n \"noUnmanagedFound\": \"未发现需要导入的技能。所有技能已在 CC Switch 统一管理中。\",\n \"unmanagedAvailable\": \"发现可导入的技能\",\n \"foundIn\": \"发现于\",\n \"local\": \"本地\",\n \"uninstallConfirm\": \"确定要卸载技能 \\\"{{name}}\\\" 吗?这将从所有应用中移除该技能,并在删除前自动创建本地备份。\",\n \"uninstallInMainPanel\": \"请在主面板中卸载技能\",\n \"notFound\": \"未找到技能\",\n \"backup\": {\n \"location\": \"备份位置: {{path}}\"\n },\n \"restoreFromBackup\": {\n \"button\": \"从备份中恢复\",\n \"title\": \"从备份中恢复\",\n \"description\": \"选择一个 Skills 备份,将文件恢复到本地并重新加入当前列表。\",\n \"empty\": \"暂无可恢复的 Skills 备份\",\n \"createdAt\": \"备份时间\",\n \"path\": \"备份路径\",\n \"restore\": \"恢复\",\n \"restoring\": \"恢复中...\",\n \"delete\": \"删除\",\n \"deleting\": \"删除中...\",\n \"deleteSuccess\": \"技能备份 {{name}} 已删除\",\n \"deleteFailed\": \"删除技能备份失败\",\n \"deleteConfirmTitle\": \"确认删除备份\",\n \"deleteConfirmMessage\": \"确定要删除技能备份 \\\"{{name}}\\\" 吗?此操作无法撤销。\",\n \"success\": \"技能 {{name}} 已从备份恢复\",\n \"failed\": \"从备份恢复失败\"\n },\n \"apps\": {\n \"claude\": \"Claude\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\",\n \"openclaw\": \"OpenClaw\"\n },\n \"installFromZip\": {\n \"button\": \"从 ZIP 安装\",\n \"installing\": \"安装中...\",\n \"successSingle\": \"技能 {{name}} 已安装\",\n \"successMultiple\": \"成功安装 {{count}} 个技能\",\n \"noSkillsFound\": \"ZIP 文件中未找到技能(需包含 SKILL.md 文件)\"\n }\n },\n \"deeplink\": {\n \"confirmImport\": \"确认导入供应商配置\",\n \"confirmImportDescription\": \"以下配置将导入到 CC Switch\",\n \"importPrompt\": \"导入提示词\",\n \"importPromptDescription\": \"请确认是否导入此系统提示词\",\n \"importMcp\": \"导入 MCP Servers\",\n \"importMcpDescription\": \"请确认是否导入这些 MCP Servers\",\n \"importSkill\": \"添加 Skill 仓库\",\n \"importSkillDescription\": \"请确认是否添加此 Skill 仓库\",\n \"promptImportSuccess\": \"提示词导入成功\",\n \"promptImportSuccessDescription\": \"已导入提示词: {{name}}\",\n \"mcpImportSuccess\": \"MCP Servers 导入成功\",\n \"mcpImportSuccessDescription\": \"成功导入 {{count}} 个服务器\",\n \"mcpPartialSuccess\": \"部分导入成功\",\n \"mcpPartialSuccessDescription\": \"成功: {{success}}, 失败: {{failed}}\",\n \"skillImportSuccess\": \"Skill 仓库添加成功\",\n \"skillImportSuccessDescription\": \"已添加仓库: {{repo}}\",\n \"app\": \"应用类型\",\n \"providerName\": \"供应商名称\",\n \"homepage\": \"官网地址\",\n \"endpoint\": \"API 端点\",\n \"apiKey\": \"API 密钥\",\n \"icon\": \"图标\",\n \"model\": \"模型\",\n \"haikuModel\": \"Haiku 模型\",\n \"sonnetModel\": \"Sonnet 模型\",\n \"opusModel\": \"Opus 模型\",\n \"multiModel\": \"多模态模型\",\n \"notes\": \"备注\",\n \"import\": \"导入\",\n \"importing\": \"导入中...\",\n \"warning\": \"请确认以上信息准确无误后再导入。导入后可在供应商列表中编辑或删除。\",\n \"parseError\": \"深链接解析失败\",\n \"importSuccess\": \"导入成功\",\n \"importSuccessDescription\": \"供应商 \\\"{{name}}\\\" 已成功导入\",\n \"importError\": \"导入失败\",\n \"configSource\": \"配置来源\",\n \"configEmbedded\": \"内嵌配置\",\n \"configRemote\": \"远程配置\",\n \"configDetails\": \"配置详情\",\n \"configUrl\": \"配置文件 URL\",\n \"configMergeError\": \"合并配置文件失败\",\n \"primaryEndpoint\": \"主\",\n \"mcp\": {\n \"title\": \"批量导入 MCP Servers\",\n \"targetApps\": \"目标应用\",\n \"serverCount\": \"MCP Servers ({{count}} 个)\",\n \"enabledWarning\": \"导入后将立即写入所有指定应用的配置文件\"\n },\n \"prompt\": {\n \"title\": \"导入系统提示词\",\n \"app\": \"应用\",\n \"name\": \"名称\",\n \"description\": \"描述\",\n \"contentPreview\": \"内容预览\",\n \"enabledWarning\": \"导入后将立即启用此提示词,其他提示词将被禁用\"\n },\n \"skill\": {\n \"title\": \"添加 Claude Skill 仓库\",\n \"repo\": \"GitHub 仓库\",\n \"directory\": \"目标目录\",\n \"branch\": \"分支\",\n \"skillsPath\": \"Skills 路径\",\n \"hint\": \"此操作将添加 Skill 仓库到列表。\",\n \"hintDetail\": \"添加后,您可以在 Skills 管理界面中选择安装具体的 Skill。\"\n },\n \"usageScript\": \"用量查询\",\n \"usageScriptEnabled\": \"已启用\",\n \"usageScriptDisabled\": \"未启用\",\n \"usageApiKey\": \"用量 API Key\",\n \"usageBaseUrl\": \"用量查询地址\",\n \"usageAutoInterval\": \"自动查询\",\n \"usageAutoIntervalValue\": \"每 {{minutes}} 分钟\"\n },\n \"iconPicker\": {\n \"search\": \"搜索图标\",\n \"searchPlaceholder\": \"输入图标名称...\",\n \"noResults\": \"未找到匹配的图标\",\n \"category\": {\n \"aiProvider\": \"AI 服务商\",\n \"cloud\": \"云平台\",\n \"tool\": \"开发工具\",\n \"other\": \"其他\"\n }\n },\n \"providerIcon\": {\n \"label\": \"图标\",\n \"colorLabel\": \"图标颜色\",\n \"selectIcon\": \"选择图标\",\n \"preview\": \"预览\",\n \"clickToChange\": \"点击更换图标\",\n \"clickToSelect\": \"点击选择图标\",\n \"color\": \"图标颜色\"\n },\n \"migration\": {\n \"success\": \"配置迁移成功\",\n \"skillsSuccess\": \"已自动导入 {{count}} 个技能到统一管理\",\n \"skillsFailed\": \"自动导入技能失败\",\n \"skillsFailedDescription\": \"请打开 Skills 页面点击“导入已有”手动导入(或重启后再试)。\"\n },\n \"agents\": {\n \"title\": \"智能体\"\n },\n \"health\": {\n \"operational\": \"正常\",\n \"degraded\": \"降级\",\n \"failed\": \"失败\",\n \"circuitOpen\": \"熔断\",\n \"consecutiveFailures\": \"连续失败 {{count}} 次\"\n },\n \"failover\": {\n \"enabled\": \"{{app}} 故障转移已启用\",\n \"disabled\": \"{{app}} 故障转移已关闭\",\n \"toggleFailed\": \"操作失败: {{detail}}\",\n \"inQueue\": \"已加入\",\n \"addQueue\": \"加入\",\n \"priority\": {\n \"tooltip\": \"故障转移优先级 {{priority}}\"\n },\n \"tooltip\": {\n \"enabled\": \"{{app}} 故障转移已启用\\n按队列优先级(P1→P2→...)选择供应商\",\n \"disabled\": \"启用 {{app}} 故障转移\\n将立即切换到队列 P1,并在失败时自动切换到下一个\"\n }\n },\n \"proxy\": {\n \"panel\": {\n \"serviceAddress\": \"服务地址\",\n \"addressCopied\": \"地址已复制\",\n \"currentProvider\": \"当前 Provider:\",\n \"waitingFirstRequest\": \"当前 Provider:等待首次请求…\",\n \"stoppedTitle\": \"路由服务已停止\",\n \"stoppedDescription\": \"使用上方开关即可启动服务\",\n \"openSettings\": \"配置路由服务\",\n \"stats\": {\n \"activeConnections\": \"活跃连接\",\n \"totalRequests\": \"总请求数\",\n \"successRate\": \"成功率\",\n \"uptime\": \"运行时间\"\n }\n },\n \"settings\": {\n \"title\": \"路由服务设置\",\n \"description\": \"配置本地路由服务器的监听地址、端口和运行参数,保存后立即生效。\",\n \"alert\": {\n \"autoApply\": \"保存后将自动同步到正在运行的路由服务,无需手动重启。\"\n },\n \"basic\": {\n \"title\": \"基础设置\",\n \"description\": \"配置路由服务监听的地址与端口。\"\n },\n \"advanced\": {\n \"title\": \"高级参数\",\n \"description\": \"控制请求的稳定性和日志记录。\"\n },\n \"timeout\": {\n \"title\": \"超时设置\",\n \"description\": \"配置流式和非流式请求的超时时间。\"\n },\n \"fields\": {\n \"listenAddress\": {\n \"label\": \"监听地址\",\n \"placeholder\": \"127.0.0.1\",\n \"description\": \"路由服务器监听的 IP 地址(推荐 127.0.0.1)\"\n },\n \"listenPort\": {\n \"label\": \"监听端口\",\n \"placeholder\": \"15721\",\n \"description\": \"路由服务器监听的端口号(1024 ~ 65535)\"\n },\n \"maxRetries\": {\n \"label\": \"最大重试次数\",\n \"placeholder\": \"3\",\n \"description\": \"请求失败时的重试次数(0 ~ 10)\"\n },\n \"requestTimeout\": {\n \"label\": \"请求超时(秒)\",\n \"placeholder\": \"0(不限)或 300\",\n \"description\": \"单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)\"\n },\n \"enableLogging\": {\n \"label\": \"启用日志记录\",\n \"description\": \"记录所有路由请求,便于排查问题\"\n },\n \"streamingFirstByteTimeout\": {\n \"label\": \"流式首字超时(秒)\",\n \"description\": \"等待首个数据块的最大时间\"\n },\n \"streamingIdleTimeout\": {\n \"label\": \"流式静默超时(秒)\",\n \"description\": \"数据块之间的最大间隔\"\n },\n \"nonStreamingTimeout\": {\n \"label\": \"非流式超时(秒)\",\n \"description\": \"非流式请求的总超时时间\"\n }\n },\n \"validation\": {\n \"addressInvalid\": \"请输入有效的IP地址\",\n \"portMin\": \"端口必须大于1024\",\n \"portMax\": \"端口必须小于65535\",\n \"retryMin\": \"重试次数不能为负\",\n \"retryMax\": \"重试次数不能超过10\",\n \"timeoutNonNegative\": \"超时时间不能为负数\",\n \"timeoutMax\": \"超时时间最多600秒\",\n \"timeoutRange\": \"请输入 0 或 10-600 之间的数值\",\n \"streamingTimeoutMin\": \"超时时间至少5秒\",\n \"streamingTimeoutMax\": \"超时时间最多300秒\"\n },\n \"actions\": {\n \"save\": \"保存配置\"\n },\n \"toast\": {\n \"saved\": \"路由配置已保存\",\n \"saveFailed\": \"保存失败: {{error}}\"\n },\n \"invalidPort\": \"端口无效,请输入 1024-65535 之间的数字\",\n \"invalidAddress\": \"地址无效,请输入 IPv4(如 127.0.0.1)、IPv6(如 ::1)或 localhost\",\n \"configSaved\": \"路由配置已保存\",\n \"configSaveFailed\": \"保存配置失败\",\n \"restartRequired\": \"修改地址或端口后需要重启路由服务才能生效\"\n },\n \"switchFailed\": \"切换失败: {{error}}\",\n \"takeover\": {\n \"hint\": \"选择要路由的应用,启用后该应用的请求将通过本地路由转发\",\n \"enabled\": \"{{app}} 路由已启用\",\n \"disabled\": \"{{app}} 路由已关闭\",\n \"failed\": \"切换路由状态失败: {{detail}}\",\n \"tooltip\": {\n \"active\": \"{{appLabel}} 路由中 - {{address}}:{{port}}\\n切换该应用供应商为热切换\",\n \"broken\": \"{{appLabel}} 路由中,但路由服务未运行\",\n \"inactive\": \"路由 {{appLabel}} 的请求,让该应用请求走本地路由\"\n }\n },\n \"failover\": {\n \"proxyRequired\": \"需要先启动路由服务才能配置故障转移\",\n \"autoSwitch\": \"自动故障转移\",\n \"autoSwitchDescription\": \"开启后将立即切换到队列 P1,并在请求失败时自动切换到队列中的下一个供应商\"\n },\n \"failoverQueue\": {\n \"title\": \"故障转移队列\",\n \"description\": \"管理各应用的供应商故障转移顺序\",\n \"info\": \"启用自动故障转移后,将按队列优先级选择供应商(P1 优先)。当请求失败时,系统会按队列顺序依次尝试下一个供应商。\",\n \"selectProvider\": \"选择供应商添加到队列\",\n \"noAvailableProviders\": \"没有可添加的供应商\",\n \"empty\": \"故障转移队列为空。添加供应商以启用自动故障转移。\",\n \"orderHint\": \"队列顺序与首页供应商列表顺序一致,可在首页拖拽调整顺序。\",\n \"dragHint\": \"拖拽供应商可调整故障转移顺序,序号越小优先级越高。\",\n \"toggleEnabled\": \"启用/禁用\",\n \"addSuccess\": \"已添加到故障转移队列\",\n \"addFailed\": \"添加失败\",\n \"removeSuccess\": \"已从故障转移队列移除\",\n \"removeFailed\": \"移除失败\",\n \"reorderSuccess\": \"队列顺序已更新\",\n \"reorderFailed\": \"更新顺序失败\",\n \"toggleFailed\": \"状态更新失败\"\n },\n \"autoFailover\": {\n \"info\": \"当故障转移队列中配置了多个供应商时,系统会在请求失败时按优先级顺序依次尝试。当某个供应商连续失败达到阈值时,熔断器会打开并在一段时间内跳过该供应商。\",\n \"configSaved\": \"自动故障转移配置已保存\",\n \"configSaveFailed\": \"保存失败\",\n \"validationFailed\": \"以下字段超出有效范围: {{fields}}\",\n \"retrySettings\": \"重试与超时设置\",\n \"failureThreshold\": \"失败阈值\",\n \"failureThresholdHint\": \"连续失败多少次后打开熔断器(建议: 3-10)\",\n \"timeout\": \"恢复等待时间(秒)\",\n \"timeoutHint\": \"熔断器打开后,等待多久后尝试恢复(建议: 30-120)\",\n \"circuitBreakerSettings\": \"熔断器设置\",\n \"successThreshold\": \"恢复成功阈值\",\n \"successThresholdHint\": \"半开状态下成功多少次后关闭熔断器\",\n \"errorRate\": \"错误率阈值 (%)\",\n \"errorRateHint\": \"错误率超过此值时打开熔断器\",\n \"minRequests\": \"最小请求数\",\n \"minRequestsHint\": \"计算错误率前的最小请求数\",\n \"explanationTitle\": \"工作原理\",\n \"failureThresholdLabel\": \"失败阈值\",\n \"failureThresholdExplain\": \"连续失败达到此次数时,熔断器打开,该供应商暂时不可用\",\n \"timeoutLabel\": \"恢复等待时间\",\n \"timeoutExplain\": \"熔断器打开后,等待此时间后尝试半开状态\",\n \"successThresholdLabel\": \"恢复成功阈值\",\n \"successThresholdExplain\": \"半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用\",\n \"errorRateLabel\": \"错误率阈值\",\n \"errorRateExplain\": \"错误率超过此值时,即使未达到失败阈值也会打开熔断器\",\n \"maxRetries\": \"最大重试次数\",\n \"timeoutSettings\": \"超时配置\",\n \"streamingFirstByte\": \"流式首字节超时\",\n \"streamingIdle\": \"流式静默超时\",\n \"nonStreaming\": \"非流式超时\",\n \"maxRetriesHint\": \"请求失败时的重试次数(0-10)\",\n \"streamingFirstByteHint\": \"等待首个数据块的最大时间,范围 1-120 秒,默认 60 秒\",\n \"streamingIdleHint\": \"数据块之间的最大间隔,范围 60-600 秒,填 0 禁用(防止中途卡住)\",\n \"nonStreamingHint\": \"非流式请求的总超时时间,范围 60-1200 秒,默认 600 秒(10 分钟)\"\n },\n \"logging\": {\n \"enabled\": \"日志记录已启用\",\n \"disabled\": \"日志记录已关闭\",\n \"failed\": \"切换日志状态失败\"\n },\n \"server\": {\n \"started\": \"路由服务已启动 - {{address}}:{{port}}\",\n \"startFailed\": \"启动路由服务失败: {{detail}}\"\n },\n \"stoppedWithRestore\": \"路由服务已关闭,已恢复所有路由配置\",\n \"stopWithRestoreFailed\": \"停止失败: {{detail}}\"\n },\n \"streamCheck\": {\n \"reachable\": \"{{providerName}} 连通正常 ({{responseTimeMs}}ms)\",\n \"reachableSlow\": \"{{providerName}} 连通但较慢 ({{responseTimeMs}}ms)\",\n \"unreachable\": \"{{providerName}} 无法连通: {{message}}\",\n \"unreachableHint\": \"无法建立连接(DNS / 连接 / TLS / 超时)。请检查 base_url 与网络。\",\n \"connectivityNote\": \"连通检测仅探测供应商地址是否可达,不发送真实模型请求。收到任意响应即视为“可达”——这不代表鉴权或模型配置一定正确。\",\n \"configSaved\": \"连通检测配置已保存\",\n \"configSaveFailed\": \"保存失败\",\n \"checkParams\": \"检查参数\",\n \"timeout\": \"超时时间(秒)\",\n \"maxRetries\": \"最大重试次数\",\n \"degradedThreshold\": \"较慢阈值(毫秒)\",\n \"error\": \"{{providerName}} 检查出错: {{error}}\"\n },\n \"proxyConfig\": {\n \"proxyEnabled\": \"路由总开关\",\n \"appTakeover\": \"路由启用\",\n \"perAppConfig\": \"应用配置\",\n \"circuitBreaker\": \"熔断器配置\",\n \"circuitBreakerSettings\": \"熔断器设置\",\n \"failureThreshold\": \"失败阈值\",\n \"successThreshold\": \"恢复阈值\",\n \"recoveryTimeout\": \"恢复等待时间\",\n \"errorRateThreshold\": \"错误率阈值\",\n \"minRequests\": \"最小请求数\",\n \"timeoutConfig\": \"超时配置\",\n \"streamingFirstByte\": \"流式首字节超时\",\n \"streamingIdle\": \"流式静默超时\",\n \"nonStreaming\": \"非流式超时\"\n },\n \"circuitBreaker\": {\n \"failureThreshold\": \"失败阈值\",\n \"successThreshold\": \"成功阈值\",\n \"timeoutSeconds\": \"超时时间(秒)\",\n \"errorRateThreshold\": \"错误率阈值 (%)\",\n \"minRequests\": \"最小请求数\",\n \"validationFailed\": \"以下字段超出有效范围: {{fields}}\",\n \"configSaved\": \"熔断器配置已保存\",\n \"saveFailed\": \"保存失败\",\n \"loading\": \"加载中...\",\n \"title\": \"熔断器配置\",\n \"description\": \"调整熔断器参数以控制故障检测和恢复行为\",\n \"failureThresholdHint\": \"连续失败多少次后打开熔断器\",\n \"timeoutSecondsHint\": \"熔断器打开后多久尝试恢复(半开状态)\",\n \"successThresholdHint\": \"半开状态下成功多少次后关闭熔断器\",\n \"errorRateThresholdHint\": \"错误率超过此值时打开熔断器\",\n \"minRequestsHint\": \"计算错误率前的最小请求数\",\n \"saveConfig\": \"保存配置\",\n \"instructionsTitle\": \"配置说明\",\n \"instructions\": {\n \"failureThreshold\": \"连续失败达到此次数时,熔断器打开\",\n \"timeout\": \"熔断器打开后,等待此时间后尝试半开\",\n \"successThreshold\": \"半开状态下,成功达到此次数时关闭熔断器\",\n \"errorRate\": \"错误率超过此值时,熔断器打开\",\n \"minRequests\": \"只有请求数达到此值后才计算错误率\"\n }\n },\n \"universalProvider\": {\n \"duplicate\": \"复制\",\n \"duplicatedAndSynced\": \"统一供应商已复制并同步\",\n \"duplicateError\": \"复制统一供应商失败\",\n \"title\": \"统一供应商\",\n \"description\": \"统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。\",\n \"add\": \"添加统一供应商\",\n \"edit\": \"编辑统一供应商\",\n \"empty\": \"还没有统一供应商\",\n \"emptyHint\": \"点击下方「添加统一供应商」按钮创建一个\",\n \"selectPreset\": \"选择预设类型\",\n \"name\": \"名称\",\n \"namePlaceholder\": \"例如:我的 NewAPI\",\n \"baseUrl\": \"API 地址\",\n \"apiKey\": \"API Key\",\n \"websiteUrl\": \"官网地址\",\n \"websiteUrlPlaceholder\": \"https://example.com(可选,用于在列表中显示)\",\n \"notes\": \"备注\",\n \"notesPlaceholder\": \"可选:添加备注信息\",\n \"enabledApps\": \"启用的应用\",\n \"modelConfig\": \"模型配置\",\n \"model\": \"模型\",\n \"sync\": \"同步到应用\",\n \"synced\": \"已同步到所有应用\",\n \"syncError\": \"同步失败\",\n \"noAppsEnabled\": \"未启用任何应用\",\n \"added\": \"统一供应商已添加\",\n \"addedAndSynced\": \"统一供应商已添加并同步\",\n \"addedButSyncFailed\": \"统一供应商已添加,但同步失败\",\n \"updated\": \"统一供应商已更新\",\n \"deleted\": \"统一供应商已删除\",\n \"addFailed\": \"统一供应商添加失败\",\n \"hint\": \"跨应用统一配置,自动同步到 Claude/Codex/Gemini\",\n \"manage\": \"管理\",\n \"loadError\": \"加载统一供应商失败\",\n \"saveError\": \"保存统一供应商失败\",\n \"deleteError\": \"删除统一供应商失败\",\n \"deleteConfirmTitle\": \"删除统一供应商\",\n \"deleteConfirmDescription\": \"确定要删除 \\\"{{name}}\\\" 吗?这将同时删除它在各应用中生成的供应商配置。\",\n \"syncConfirmTitle\": \"同步统一供应商\",\n \"syncConfirmDescription\": \"同步 \\\"{{name}}\\\" 将会覆盖 Claude、Codex 和 Gemini 中关联的供应商配置。确定要继续吗?\",\n \"syncConfirm\": \"同步\",\n \"saveAndSync\": \"保存并同步\",\n \"savedAndSynced\": \"已保存并同步到所有应用\",\n \"saveAndSyncError\": \"保存并同步失败\",\n \"configJsonPreview\": \"配置 JSON 预览\",\n \"configJsonPreviewHint\": \"以下是将要同步到各应用的配置内容(仅覆盖显示的字段,保留其他自定义配置)\"\n },\n \"omo\": {\n \"editProfile\": \"编辑 OMO 配置\",\n \"newProfile\": \"新建 OMO 配置\",\n \"profileName\": \"名称\",\n \"mainAgents\": \"主 Agent\",\n \"subAgents\": \"子 Agent\",\n \"categories\": \"分类\",\n \"customAgents\": \"自定义 Agent\",\n \"noCustomAgents\": \"暂无自定义 Agent\",\n \"otherFields\": \"其他配置\",\n \"globalConfig\": \"OMO 全局配置\",\n \"globalConfigShort\": \"OMO 配置\",\n \"globalConfigSaved\": \"全局配置已保存\",\n \"addProfile\": \"添加 OMO 配置\",\n \"disabledItems\": \"禁用项设置\",\n \"advanced\": \"高级设置\",\n \"profileCreated\": \"OMO 配置已创建\",\n \"profileUpdated\": \"OMO 配置已更新\",\n \"invalidJson\": \"其他字段包含无效 JSON\",\n \"confirmDelete\": \"删除配置\",\n \"confirmDeleteMsg\": \"确定删除 \\\"{{name}}\\\" 吗?\",\n \"profileDeleted\": \"配置已删除\",\n \"imported\": \"已导入为 \\\"{{name}}\\\"\",\n \"import\": \"导入\",\n \"global\": \"全局\",\n \"empty\": \"暂无配置。点击 + 添加或从本地导入。\",\n \"applied\": \"已应用\",\n \"apply\": \"应用\",\n \"enable\": \"启用\",\n \"enabled\": \"启用中\",\n \"disabled\": \"OMO 已停用\",\n \"disableFailed\": \"停用 OMO 失败: {{error}}\",\n \"selectPlaceholder\": \"请选择...\",\n \"clear\": \"清空\",\n \"clearWrapped\": \"(清空)\",\n \"defaultWrapped\": \"(默认)\",\n \"variantPlaceholder\": \"思考等级\",\n \"selectEnabledModel\": \"选择已配置模型\",\n \"selectModel\": \"选择已配置模型\",\n \"recommendedHint\": \"推荐: {{model}}\",\n \"searchModel\": \"搜索模型...\",\n \"selectModelFirst\": \"先选择模型\",\n \"noEnabledModels\": \"暂无已配置模型\",\n \"noVariantsForModel\": \"该模型无思考等级\",\n \"currentValueNotEnabled\": \"{{value}}(当前值,未配置)\",\n \"currentValueUnavailable\": \"{{value}}(当前值,不可用)\",\n \"advancedLabel\": \"高级参数\",\n \"advancedJsonInvalid\": \"高级参数 JSON 无效\",\n \"advancedJsonHint\": \"temperature, top_p, budgetTokens, prompt_append, permission 等,留空使用默认值\",\n \"noEnabledModelsWarning\": \"当前没有可用的已配置模型,请先配置 OpenCode 模型\",\n \"modelSourcePartialWarning\": \"部分供应商模型配置无效,已自动跳过。\",\n \"modelSourceFallbackWarning\": \"读取 live 供应商状态失败,已回退到已配置供应商列表。\",\n \"importLocalReplaceSuccess\": \"已从本地文件导入并覆盖 Agent/Category/Other Fields\",\n \"importLocalFailed\": \"读取本地文件失败: {{error}}\",\n \"agentKeyPlaceholder\": \"agent 键名\",\n \"categoryKeyPlaceholder\": \"分类键名\",\n \"modelNamePlaceholder\": \"模型名\",\n \"custom\": \"自定义\",\n \"customCategories\": \"自定义分类\",\n \"modelConfiguration\": \"模型配置\",\n \"fillRecommended\": \"填充推荐\",\n \"fillRecommendedSuccess\": \"已填充 {{count}} 个推荐模型\",\n \"fillRecommendedPartial\": \"已填充 {{filled}} 个推荐模型,{{unmatched}} 个未匹配\",\n \"fillRecommendedMostlyUnmatched\": \"仅填充了 {{filled}} 个,{{unmatched}} 个未匹配(如 {{examples}})。请配置提供这些模型的供应商,或手动选择替代项。\",\n \"fillRecommendedAllSet\": \"所有槽位已有模型配置\",\n \"fillRecommendedNoMatch\": \"推荐模型在已配置的供应商中未找到匹配(如 {{examples}})\",\n \"configSummary\": \"已配置 {{agents}} 个 Agent,{{categories}} 个 Category · 点击 ⚙ 展开高级参数\",\n \"enabledModelsCount\": \"可选已配置模型 {{count}} 个\",\n \"source\": \"来源:\",\n \"otherFieldsJson\": \"其他字段 (JSON)\",\n \"slimOtherFieldsHint\": \"OMO Slim 的 council、fallback、multiplexer、disabled_mcps、todoContinuation 等顶层配置请写在这里。\",\n \"searchOrType\": \"搜索或输入自定义值...\",\n \"noMatches\": \"无匹配项\",\n \"jsonMustBeObject\": \"{{field}} 必须是 JSON 对象\",\n \"jsonInvalid\": \"{{field}} 包含无效 JSON\",\n \"importGlobalSuccess\": \"已从本地文件导入全局配置(未保存)\",\n \"importGlobalFailed\": \"读取本地文件失败: {{error}}\",\n \"importLocal\": \"从本地导入\",\n \"saveGlobalConfig\": \"保存全局配置\",\n \"schemaUrl\": \"$schema\",\n \"resetDefault\": \"重置默认\",\n \"sisyphusAgentConfig\": \"Sisyphus Agent 设置\",\n \"disabledAgents\": \"Agents\",\n \"disabledAgentsPlaceholder\": \"禁用的 Agents\",\n \"disabledMcps\": \"MCPs\",\n \"disabledMcpsPlaceholder\": \"禁用的 MCPs\",\n \"disabledHooks\": \"Hooks\",\n \"disabledHooksPlaceholder\": \"禁用的 Hooks\",\n \"disabledSkills\": \"Skills\",\n \"disabledSkillsPlaceholder\": \"禁用的 Skills\",\n \"advancedLsp\": \"LSP 配置\",\n \"advancedExperimental\": \"实验性功能\",\n \"advancedBackgroundTask\": \"后台任务\",\n \"advancedBrowserAutomation\": \"浏览器自动化\",\n \"advancedClaudeCode\": \"Claude Code\",\n \"agentDesc\": {\n \"sisyphus\": \"主编排者\",\n \"hephaestus\": \"自主深度工作者\",\n \"prometheus\": \"战略规划者\",\n \"atlas\": \"任务管理者\",\n \"oracle\": \"战略顾问\",\n \"librarian\": \"多仓库研究员\",\n \"explore\": \"快速代码搜索\",\n \"multimodalLooker\": \"媒体分析器\",\n \"metis\": \"规划前分析顾问\",\n \"momus\": \"计划审查者\",\n \"sisyphusJunior\": \"委托任务执行器\"\n },\n \"agentTooltip\": {\n \"sisyphus\": \"主编排器,负责任务规划、委派与并行执行,使用扩展思考(32k 预算),通过 TODO 驱动工作流确保任务完成。\",\n \"atlas\": \"主编排器(持有 TODO 列表),负责执行阶段的任务分发与协调,不直接完成所有工作而是委派给专业代理。\",\n \"prometheus\": \"战略规划师,通过访谈模式收集需求并制定详细工作计划,仅能在 .sisyphus/ 目录读写 Markdown 文件,从不直接写代码。\",\n \"hephaestus\": \"自主深度工作者(「合法工匠」),受 AmpCode 深度模式启发,目标导向执行,行动前会并行启动 2-5 个探索/图书管理员代理进行研究。\",\n \"oracle\": \"架构决策与调试顾问,只读咨询代理,提供出色的逻辑推理和深度分析,不能写文件或委派任务。\",\n \"librarian\": \"多仓库分析与文档检索专家,深度理解代码库并提供基于证据的答案,擅长查找官方文档和开源实现示例。\",\n \"explore\": \"快速代码库探索与上下文 grep 专家,使用轻量级模型进行高速搜索,是理解代码结构的先锋。\",\n \"multimodalLooker\": \"视觉内容专家,分析 PDF、图片、图表等非文本媒体,提取其中的信息与洞察。\",\n \"metis\": \"计划顾问,在规划前进行预分析,识别隐藏意图、模糊点和 AI 失败点,防止过度工程化。\",\n \"momus\": \"计划评审员,高精度验证计划的清晰度、可验证性和完整性,拒绝并要求修订直到计划完美。\",\n \"sisyphusJunior\": \"类别生成的执行器,由 category 参数自动生成,专注于执行分配的任务且不能再委派,防止无限委派循环。\"\n },\n \"categoryDesc\": {\n \"visualEngineering\": \"视觉/前端工程\",\n \"ultrabrain\": \"超级思考\",\n \"deep\": \"深度工作\",\n \"artistry\": \"创意/文艺\",\n \"quick\": \"快速响应\",\n \"unspecifiedLow\": \"通用低配\",\n \"unspecifiedHigh\": \"通用高配\",\n \"writing\": \"写作\"\n },\n \"categoryTooltip\": {\n \"visualEngineering\": \"前端与视觉工程类别,专注于 UI/UX 设计、样式、动画和界面实现,默认使用 Gemini 3.5 Flash 模型。\",\n \"ultrabrain\": \"深度逻辑推理类别,用于需要广泛分析的复杂架构决策,默认使用 GPT-5.3 Codex 的超高推理变体。\",\n \"deep\": \"深度自主问题解决类别,目标导向执行,行动前进行彻底研究,适用于需要深度理解的棘手问题。\",\n \"artistry\": \"高度创意与艺术性任务类别,激发新颖想法和创造性解决方案,默认使用 Gemini 3.5 Flash 的高推理变体。\",\n \"quick\": \"轻量任务类别,用于单文件修改、错别字修复、简单调整等琐碎工作,默认使用 Claude Haiku 4.5 快速模型。\",\n \"unspecifiedLow\": \"未归类低工作量任务类别,适用于不适合其他类别且工作量较小的任务,默认使用 Claude Sonnet 4.5。\",\n \"unspecifiedHigh\": \"未归类高工作量任务类别,适用于不适合其他类别且工作量较大的任务,默认使用 Claude Opus 4.8 的最大变体。\",\n \"writing\": \"写作类别,专注于文档、散文和技术写作,默认使用 Kimi K2.5 模型。\"\n },\n \"slimAgentDesc\": {\n \"orchestrator\": \"编排者\",\n \"oracle\": \"神谕者\",\n \"librarian\": \"图书管理员\",\n \"explorer\": \"探索者\",\n \"designer\": \"设计师\",\n \"fixer\": \"修复者\",\n \"council\": \"议会\"\n },\n \"slimAgentTooltip\": {\n \"orchestrator\": \"编写执行代码,编排多代理工作流,召唤专家\",\n \"oracle\": \"根本原因分析、架构审查、调试指导(只读)\",\n \"librarian\": \"文档查询、GitHub 代码搜索(只读)\",\n \"explorer\": \"正则搜索、AST 模式匹配、文件发现(只读)\",\n \"designer\": \"现代响应式设计、CSS/Tailwind 精通\",\n \"fixer\": \"代码实现、重构、测试、验证\",\n \"council\": \"多模型共识代理,并行运行多个只读 councillor,再由 master 汇总最终答案。\"\n }\n },\n \"openclawConfig\": {\n \"defaultModel\": {\n \"title\": \"默认模型\",\n \"description\": \"配置 OpenClaw 的默认主模型和回退模型\",\n \"primary\": \"主模型\",\n \"primaryPlaceholder\": \"例如: deepseek/deepseek-chat\",\n \"fallbacks\": \"回退模型\",\n \"fallbacksPlaceholder\": \"例如: openrouter/anthropic/claude-sonnet-4.5\",\n \"addFallback\": \"添加回退模型\",\n \"saved\": \"默认模型配置已保存\",\n \"saveFailed\": \"保存默认模型失败\"\n },\n \"modelCatalog\": {\n \"title\": \"模型目录\",\n \"description\": \"配置可用模型的别名和允许列表\",\n \"modelId\": \"模型 ID\",\n \"modelIdPlaceholder\": \"例如: deepseek/deepseek-chat\",\n \"alias\": \"别名\",\n \"aliasPlaceholder\": \"例如: DeepSeek\",\n \"addEntry\": \"添加模型\",\n \"removeEntry\": \"移除\",\n \"saved\": \"模型目录已保存\",\n \"saveFailed\": \"保存模型目录失败\",\n \"empty\": \"暂无模型目录配置\",\n \"emptyHint\": \"添加模型到目录以配置别名\"\n },\n \"suggestedDefaults\": \"应用建议默认配置\",\n \"suggestedDefaultsHint\": \"使用此预设推荐的默认模型配置\"\n },\n \"subscription\": {\n \"title\": \"订阅额度\",\n \"fiveHour\": \"5小时\",\n \"sevenDay\": \"7天\",\n \"sevenDayOpus\": \"7天(Opus)\",\n \"sevenDaySonnet\": \"7天(Sonnet)\",\n \"thirtyDay\": \"30天\",\n \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每周\",\n \"monthly\": \"每月\",\n \"copilotPremium\": \"高级请求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}}后重置\",\n \"extraUsage\": \"超额用量\",\n \"expired\": \"会话已过期\",\n \"expiredHint\": \"请运行 {{tool}} 命令刷新登录\",\n \"queryFailed\": \"查询失败\",\n \"refresh\": \"刷新\"\n }\n}\n"}}, "src/i18n/locales/en.json": {"1e398a3709f03db3fe5d989827090386b9dcd3be": {"t": 1785674714.7121363, "n": 160194, "h": "f5379022b4324b06", "c": "{\n \"app\": {\n \"title\": \"CC Switch\",\n \"description\": \"All-in-One Assistant for Claude Code, Codex & Gemini CLI\"\n },\n \"common\": {\n \"add\": \"Add\",\n \"edit\": \"Edit\",\n \"delete\": \"Delete\",\n \"save\": \"Save\",\n \"saving\": \"Saving...\",\n \"cancel\": \"Cancel\",\n \"confirm\": \"Confirm\",\n \"close\": \"Close\",\n \"done\": \"Done\",\n \"settings\": \"Settings\",\n \"about\": \"About\",\n \"version\": \"Version\",\n \"loading\": \"Loading...\",\n \"notInstalled\": \"Not installed\",\n \"success\": \"Success\",\n \"error\": \"Error\",\n \"unknown\": \"Unknown\",\n \"enterValidValue\": \"Please enter a valid value\",\n \"clear\": \"Clear\",\n \"toggleTheme\": \"Toggle theme\",\n \"format\": \"Format\",\n \"formatSuccess\": \"Formatted successfully\",\n \"formatError\": \"Format failed: {{error}}\",\n \"copy\": \"Copy\",\n \"view\": \"View\",\n \"back\": \"Back\",\n \"refresh\": \"Refresh\",\n \"refreshing\": \"Refreshing...\",\n \"import\": \"Import\",\n \"all\": \"All\",\n \"search\": \"Search\",\n \"reset\": \"Reset\",\n \"actions\": \"Actions\",\n \"deleting\": \"Deleting...\",\n \"auto\": \"Auto\",\n \"enabled\": \"Enabled\",\n \"notSet\": \"Not Set\"\n },\n \"firstRunNotice\": {\n \"title\": \"Welcome to CC Switch\",\n \"bodyDefault\": \"CC Switch lets you switch between multiple Claude Code / Codex / Gemini CLI providers with a single click. If you already had these tools configured, CC Switch has saved your existing setup as a provider named “default” so none of your previous configuration is lost.\",\n \"bodyOfficial\": \"An “Official” preset is also included in the list — click it any time you want to switch back to the official default. CC Switch automatically backs up your current config to “default” before switching, so you can move back and forth freely. That's how CC Switch works 😊\",\n \"confirm\": \"Got it\"\n },\n \"apiKeyInput\": {\n \"placeholder\": \"Enter API Key\",\n \"show\": \"Show API Key\",\n \"hide\": \"Hide API Key\"\n },\n \"jsonEditor\": {\n \"mustBeObject\": \"Configuration must be a JSON object, not an array or other type\",\n \"invalidJson\": \"Invalid JSON format\"\n },\n \"commonConfig\": {\n \"guideTitle\": \"What is a Common Config Snippet?\",\n \"guidePurpose\": \"It lets you share non-sensitive settings (plugins, environment variables, etc.) across different providers. These settings won't be lost when you switch providers.\",\n \"guideUsage\": \"How to use: ① Click \\\"Extract from Editor\\\" to save the common parts ② Check \\\"Apply Common Config\\\" when creating a new provider\",\n \"guideReExtract\": \"If you've newly installed plugins or hooks, re-extract the common config so they sync to other providers.\",\n \"guideReassurance\": \"Don't worry — your original configuration is safely stored in the default provider and won't be lost.\",\n \"emptyTitle\": \"No common config snippet yet\",\n \"emptyHint\": \"Click \\\"Extract from Editor\\\" below to extract reusable parts from your current configuration\"\n },\n \"claudeConfig\": {\n \"configLabel\": \"Claude Code settings.json (JSON) *\",\n \"writeCommonConfig\": \"Apply Common Config\",\n \"editCommonConfig\": \"Edit Common Config\",\n \"editCommonConfigTitle\": \"Edit Common Config Snippet\",\n \"commonConfigHint\": \"This snippet will be merged into settings.json when 'Apply Common Config' is checked\",\n \"fullSettingsHint\": \"Full Claude Code settings.json content\",\n \"extractFromCurrent\": \"Extract from Editor\",\n \"extractNoCommonConfig\": \"No common config available to extract from editor\",\n \"extractFailed\": \"Extract failed: {{error}}\",\n \"saveFailed\": \"Save failed: {{error}}\",\n \"hideAttribution\": \"Hide AI Attribution\",\n \"enableTeammates\": \"Teammates Mode\",\n \"enableToolSearch\": \"Enable Tool Search\",\n \"effortMax\": \"Max Effort Thinking\",\n \"disableAutoUpgrade\": \"Disable Auto-Upgrade\"\n },\n \"header\": {\n \"viewOnGithub\": \"View on GitHub\",\n \"toggleDarkMode\": \"Switch to Dark Mode\",\n \"toggleLightMode\": \"Switch to Light Mode\",\n \"addProvider\": \"Add Provider\",\n \"switchToChinese\": \"Switch to Chinese\",\n \"switchToEnglish\": \"Switch to English\",\n \"enterEditMode\": \"Enter Edit Mode\",\n \"exitEditMode\": \"Exit Edit Mode\",\n \"windowMinimize\": \"Minimize window\",\n \"windowMaximize\": \"Maximize window\",\n \"windowRestore\": \"Restore window\",\n \"windowClose\": \"Close window\"\n },\n \"provider\": {\n \"connectivityCheck\": \"Connectivity check\",\n \"tabProvider\": \"Provider\",\n \"tabUniversal\": \"Universal\",\n \"noProviders\": \"No providers added yet\",\n \"noProvidersDescription\": \"If you already have a config, click \\\"Import Current Config\\\" — all data will be safely saved in a default provider\",\n \"noProvidersDescriptionSnippet\": \"Settings other than API key and endpoint (e.g. plugins) will be saved to a common config snippet for sharing across providers\",\n \"importCurrent\": \"Import Current Config\",\n \"importFromClaude\": \"Import existing providers from Claude Code\",\n \"importCurrentDescription\": \"Import current live config as default provider\",\n \"currentlyUsing\": \"Currently Using\",\n \"enable\": \"Enable\",\n \"inUse\": \"In Use\",\n \"blockedByProxyHint\": \"Can't switch to an official provider while proxy takeover is active\",\n \"editProvider\": \"Edit Provider\",\n \"editProviderHint\": \"Configuration will be applied to the current provider immediately after update.\",\n \"deleteProvider\": \"Delete Provider\",\n \"addNewProvider\": \"Add New Provider\",\n \"addFooterHint\": \"💡 After choosing a preset, fill in the fields below (e.g. API Key)\",\n \"addClaudeProvider\": \"Add Claude Code Provider\",\n \"addCodexProvider\": \"Add Codex Provider\",\n \"addGeminiProvider\": \"Add Gemini Provider\",\n \"addOpenCodeProvider\": \"Add OpenCode Provider\",\n \"addToConfig\": \"Add\",\n \"removeFromConfig\": \"Remove\",\n \"setAsDefault\": \"Set Default\",\n \"isDefault\": \"Current Default\",\n \"inConfig\": \"Added\",\n \"addProviderHint\": \"Fill in the information to quickly switch providers in the list.\",\n \"editClaudeProvider\": \"Edit Claude Code Provider\",\n \"editCodexProvider\": \"Edit Codex Provider\",\n \"configError\": \"Configuration Error\",\n \"notConfigured\": \"Not configured for official website\",\n \"applyToClaudePlugin\": \"Apply to Claude plugin\",\n \"removeFromClaudePlugin\": \"Remove from Claude plugin\",\n \"dragToReorder\": \"Drag to reorder\",\n \"dragHandle\": \"Drag to reorder\",\n \"searchPlaceholder\": \"Search name, notes, or URL...\",\n \"searchAriaLabel\": \"Search providers\",\n \"searchScopeHint\": \"Matches provider name, notes, and URL.\",\n \"searchCloseHint\": \"Press Esc to close\",\n \"searchCloseAriaLabel\": \"Close provider search\",\n \"noSearchResults\": \"No providers match your search.\",\n \"duplicate\": \"Duplicate\",\n \"sortUpdateFailed\": \"Failed to update sort order\",\n \"configureUsage\": \"Configure usage query\",\n \"officialPartner\": \"Official Partner\",\n \"managedByHermes\": \"Hermes Managed\",\n \"managedByHermesHint\": \"Defined in Hermes' providers: dict. Edit or remove it via Hermes Web UI.\",\n \"openTerminal\": \"Open Terminal\",\n \"terminalOpened\": \"Terminal opened\",\n \"terminalOpenFailed\": \"Failed to open terminal\",\n \"name\": \"Provider Name\",\n \"namePlaceholder\": \"e.g., Claude Official\",\n \"websiteUrl\": \"Website URL\",\n \"notes\": \"Notes\",\n \"notesPlaceholder\": \"e.g., Company dedicated account\",\n \"configJson\": \"Config JSON\",\n \"writeCommonConfig\": \"Apply Common Config\",\n \"editCommonConfigButton\": \"Edit common config\",\n \"configJsonHint\": \"Please fill in complete Claude Code configuration\",\n \"editCommonConfigTitle\": \"Edit common config snippet\",\n \"editCommonConfigHint\": \"Common config snippet will be merged into all providers that enable it\",\n \"addProvider\": \"Add Provider\",\n \"sortUpdated\": \"Sort order updated\",\n \"usageSaved\": \"Usage query configuration saved\",\n \"usageSaveFailed\": \"Failed to save usage query configuration\",\n \"geminiConfig\": \"Gemini Configuration\",\n \"geminiConfigHint\": \"Use .env format to configure Gemini\",\n \"form\": {\n \"gemini\": {\n \"model\": \"Model\",\n \"oauthTitle\": \"OAuth Authentication Mode\",\n \"oauthHint\": \"Google official uses OAuth personal authentication, no need to fill in API Key. The browser will automatically open for login on first use.\",\n \"apiKeyPlaceholder\": \"Enter Gemini API Key\"\n }\n }\n },\n \"claudeCode\": {\n \"needsRouting\": \"Needs Routing\",\n \"noRoutingSupport\": \"No Routing Support\"\n },\n \"codex\": {\n \"needsRouting\": \"Needs Routing\",\n \"noRoutingSupport\": \"No Routing Support\",\n \"officialRouting\": \"Official Account Routing\",\n \"nativeLogin\": \"Codex Sign-in\"\n },\n \"claudeDesktop\": {\n \"officialNotice\": \"Claude Desktop official providers use the built-in 1P sign-in. No API key or endpoint URL is required.\",\n \"mode\": \"Model handling\",\n \"modeDirect\": \"Direct\",\n \"modeProxy\": \"Requires routing\",\n \"modelMappingToggle\": \"Needs model mapping\",\n \"modelMappingOffHint\": \"Direct mode works only when the provider accepts Claude Desktop's three role IDs (claude-sonnet-* / claude-opus-* / claude-haiku-*); for any other model name (including legacy IDs like claude-3-5-sonnet-…), enable this switch to use mapping.\",\n \"modelMappingOnHint\": \"Claude Desktop only accepts the three role IDs claude-sonnet-* / claude-opus-* / claude-haiku-*. When enabled, CC Switch maps these three roles to your provider's actual models and keeps local routing running while in use.\",\n \"routeMapTitle\": \"Model mapping\",\n \"routeMapHint\": \"Fill in the actual request model for each of the Sonnet, Opus, and Haiku tiers. Use the menu display name for branded models such as DeepSeek or Kimi. Any tier left blank inherits the Sonnet tier's model (or the first tier you filled in), so the Haiku that sub-agents call is always available.\",\n \"routeModelLabel\": \"Model role\",\n \"routeRoleSonnet\": \"Sonnet\",\n \"routeRoleOpus\": \"Opus\",\n \"routeRoleFable\": \"Fable\",\n \"routeRoleHaiku\": \"Haiku\",\n \"labelOverrideLabel\": \"Menu display name\",\n \"upstreamModelLabel\": \"Requested model\",\n \"supports1mLabel\": \"Declare 1M\",\n \"supports1mShort\": \"1M\",\n \"directModelListTitle\": \"Manually specify Claude Desktop models (advanced, optional)\",\n \"directModelListCollapsedHint\": \"Native Claude model providers usually do not need this. Claude Desktop will fetch /v1/models automatically.\",\n \"directModelListHint\": \"Only fill this when the provider's /v1/models is unavailable or does not return Claude Desktop-recognized Sonnet / Opus / Haiku model IDs. Checking 1M declares 1M context support to Claude Desktop.\",\n \"directModelInvalid\": \"Direct models must use Claude Desktop-recognized Sonnet / Opus / Haiku model IDs\",\n \"addModel\": \"Add model\",\n \"addRoute\": \"Add model\",\n \"routeInvalid\": \"Choose a model role and enter the requested model\",\n \"routesRequired\": \"At least one model mapping is required\",\n \"statusTitle\": \"Claude Desktop configuration needs attention\",\n \"statusUnsupported\": \"This platform does not support writing Claude Desktop 3P configuration yet.\",\n \"statusStaleRawModels\": \"The Claude Desktop profile contains model IDs outside the Sonnet / Opus / Haiku role families. Newer Claude Desktop versions may reject it; switch to the current provider again to repair it.\",\n \"statusMissingRouteMappings\": \"The current provider has model mapping enabled but no valid routes. Edit the provider and add at least one mapping.\",\n \"statusGatewayTokenMissing\": \"The local routing token has not been generated yet. Switching to this provider again will write a new local token.\",\n \"statusBaseUrlMismatch\": \"The Claude Desktop profile points to a different URL than the current provider. Current: {{actual}}; expected: {{expected}}. Switch to the current provider again to repair it.\",\n \"route\": {\n \"tooltip\": {\n \"active\": \"Claude Desktop local routing is running - {{address}}:{{port}}\",\n \"inactive\": \"Turn on Claude Desktop local routing for providers that need model mapping or format conversion. Configured address: {{address}}:{{port}}\"\n }\n }\n },\n \"notifications\": {\n \"providerAdded\": \"Provider added\",\n \"providerSaved\": \"Provider configuration saved\",\n \"providerDeleted\": \"Provider deleted successfully\",\n \"switchSuccess\": \"Switch successful!\",\n \"codexRestartRequired\": \"Switched successfully. Restart the client to apply changes.\",\n \"claudeDesktopRestartRequired\": \"Switched successfully. Restart Claude Desktop to apply changes.\",\n \"claudeDesktopProxyRestartRequired\": \"Switched successfully. Keep CC Switch running and restart Claude Desktop to apply changes.\",\n \"addToConfigSuccess\": \"Added to config\",\n \"removeFromConfigSuccess\": \"Removed from config\",\n \"switchFailedTitle\": \"Switch failed\",\n \"switchFailed\": \"Switch failed: {{error}}\",\n \"autoImported\": \"Default provider created from existing configuration\",\n \"addFailed\": \"Failed to add provider: {{error}}\",\n \"saveFailed\": \"Save failed: {{error}}\",\n \"saveFailedGeneric\": \"Save failed, please try again\",\n \"appliedToClaudePlugin\": \"Applied to Claude plugin\",\n \"removedFromClaudePlugin\": \"Removed from Claude plugin\",\n \"syncClaudePluginFailed\": \"Sync Claude plugin failed\",\n \"skipClaudeOnboardingFailed\": \"Failed to skip Claude Code first-run confirmation\",\n \"clearClaudeOnboardingSkipFailed\": \"Failed to restore Claude Code first-run confirmation\",\n \"updateSuccess\": \"Provider updated successfully\",\n \"updateFailed\": \"Failed to update provider: {{error}}\",\n \"deleteSuccess\": \"Provider deleted\",\n \"deleteFailed\": \"Failed to delete provider: {{error}}\",\n \"settingsSaved\": \"Settings saved\",\n \"settingsSaveFailed\": \"Failed to save settings: {{error}}\",\n \"proxyRequiredForSwitch\": \"This provider {{reason}}, requires the routing service to work properly. Start routing first.\",\n \"proxyReasonCopilot\": \"uses GitHub Copilot as a Claude provider\",\n \"proxyReasonOpenAIChat\": \"uses OpenAI Chat API format\",\n \"proxyReasonOpenAIResponses\": \"uses OpenAI Responses API format\",\n \"proxyReasonFullUrl\": \"has full URL connection mode enabled\",\n \"openAIFormatHint\": \"This provider uses OpenAI-compatible format and requires the routing service to be enabled\",\n \"copilotProxyHint\": \"GitHub Copilot as a Claude provider always requires local routing; routing automatically selects Chat Completions or Responses based on the current model.\",\n \"openLinkFailed\": \"Failed to open link\",\n \"openclawModelsRegistered\": \"Models have been registered to /model list\",\n \"openclawDefaultModelSet\": \"Set as default model\",\n \"openclawDefaultModelSetFailed\": \"Failed to set default model\",\n \"openclawNoModels\": \"No models configured\",\n \"backfillWarning\": \"Switched successfully, but failed to save changes back to the previous provider\",\n \"windowControlFailed\": \"Window control failed: {{error}}\",\n \"officialBlockedByProxy\": \"Cannot switch to official provider while local routing is active. Using routing with official APIs may cause account bans.\",\n \"proxyOfficialWarning\": \"Current provider {{name}} is official. Consider switching to a third-party provider before using local routing.\"\n },\n \"confirm\": {\n \"deleteProvider\": \"Delete Provider\",\n \"deleteProviderMessage\": \"Are you sure you want to delete provider \\\"{{name}}\\\"? This action cannot be undone.\",\n \"removeProvider\": \"Remove Provider\",\n \"removeProviderMessage\": \"Are you sure you want to remove provider \\\"{{name}}\\\" from the configuration?\\n\\nAfter removal, this provider will no longer be active, but the configuration data will be retained in CC Switch. You can re-add it at any time.\",\n \"proxy\": {\n \"title\": \"Enable Local Routing\",\n \"message\": \"Local routing is an advanced feature. Please make sure you understand how it works before enabling.\\n\\nWe recommend consulting the relevant documentation or your provider for proper configuration.\",\n \"confirm\": \"I understand, enable\"\n },\n \"failover\": {\n \"title\": \"Enable Failover\",\n \"message\": \"Failover is an advanced feature. Please make sure you understand how it works before enabling.\\n\\nWe recommend configuring provider priorities in the failover queue first.\",\n \"confirm\": \"I understand, enable\"\n },\n \"unifyCodexHistory\": {\n \"title\": \"Unified Codex session history\",\n \"message\": \"When enabled, the official subscription and third-party providers share one session history list. Note: resuming an old session across providers may fail because its encrypted_content reasoning cannot be decrypted by another backend.\\n\\nYou can also migrate your existing official session history into the shared list (originals are backed up to ~/.cc-switch/backups first and can be restored when you turn this off).\",\n \"migrateExisting\": \"Also migrate existing official session history\",\n \"confirm\": \"I understand, enable\"\n },\n \"unifyCodexHistoryOff\": {\n \"title\": \"Turn off unified session history\",\n \"message\": \"After turning this off, the official subscription and third-party providers return to separate history lists. Sessions created while it was on cannot be attributed to a provider, so they stay in the third-party history and the official subscription will not see them.\",\n \"restoreBackup\": \"Restore the official sessions migrated at enable time back to the official history (exact restore from backup)\",\n \"confirm\": \"Turn off\"\n },\n \"usage\": {\n \"title\": \"Configure Usage Query\",\n \"message\": \"Usage query requires a custom script or API parameters. Please make sure you have obtained the necessary information from your provider.\\n\\nIf unsure how to configure, please consult your provider's documentation first.\",\n \"confirm\": \"I understand, configure\"\n },\n \"autoSync\": {\n \"title\": \"Enable Auto Sync\",\n \"message\": \"When auto sync is enabled, every database change will be automatically uploaded to the WebDAV server.\\n\\nThis may result in significant network traffic. Please ensure your network and WebDAV service can handle frequent data transfers.\",\n \"confirm\": \"I understand, enable\"\n },\n \"commonConfig\": {\n \"title\": \"About Common Config\",\n \"message\": \"The \\\"Common Config Snippet\\\" lets you share plugins, environment variables, and other settings across different providers — so they won't be lost when you switch.\\n\\nHow to use:\\n① Edit a provider → click \\\"Edit Common Config\\\" → \\\"Extract from Editor\\\"\\n② When creating a new provider, check \\\"Apply Common Config\\\" (enabled by default)\\n\\nIf you've newly installed plugins or hooks, re-extract the common config to keep them in sync.\",\n \"confirm\": \"Got it\"\n }\n },\n \"settings\": {\n \"title\": \"Settings\",\n \"general\": \"General\",\n \"tabGeneral\": \"General\",\n \"tabAuth\": \"Auth\",\n \"tabAdvanced\": \"Advanced\",\n \"tabProxy\": \"Routing\",\n \"authCenter\": {\n \"title\": \"OAuth Authentication Center\",\n \"description\": \"Use your other subscriptions in Claude Code — please be mindful of compliance risks.\",\n \"beta\": \"Beta\",\n \"copilotDescription\": \"Manage GitHub Copilot accounts\",\n \"codexOauthDescription\": \"Manage ChatGPT accounts\"\n },\n \"advanced\": {\n \"configDir\": {\n \"title\": \"Configuration Directory\",\n \"description\": \"Manage storage paths for Claude, Codex and Gemini configurations\"\n },\n \"proxy\": {\n \"title\": \"Local Routing\",\n \"description\": \"Control routing service toggle, view status and port info\",\n \"enableFeature\": \"Show Routing Toggle on Main Page\",\n \"enableFeatureDescription\": \"When enabled, the routing and failover toggles will appear at the top of the main page\",\n \"enableFailoverToggle\": \"Show Failover Toggle on Main Page\",\n \"enableFailoverToggleDescription\": \"When enabled, the failover toggle will appear independently at the top of the main page\",\n \"running\": \"Running\",\n \"stopped\": \"Stopped\"\n },\n \"connectivityCheck\": {\n \"title\": \"Connectivity Check Settings\",\n \"description\": \"Configure timeout, retries, and the slow-response threshold for provider reachability checks\"\n },\n \"failover\": {\n \"title\": \"Auto Failover\",\n \"description\": \"Configure failover queue and circuit breaker strategy\"\n },\n \"pricing\": {\n \"title\": \"Cost Pricing\",\n \"description\": \"Manage token pricing rules for each model\"\n },\n \"globalProxy\": {\n \"title\": \"Global Outbound Proxy\",\n \"description\": \"Configure proxy for CC Switch to access external APIs\"\n },\n \"data\": {\n \"title\": \"Data Management\",\n \"description\": \"Import and export local configuration data\"\n },\n \"backup\": {\n \"title\": \"Backup & Restore\",\n \"description\": \"Manage automatic backups, view and restore database snapshots\"\n },\n \"cloudSync\": {\n \"title\": \"Cloud Sync\",\n \"description\": \"Sync data across devices via WebDAV\"\n },\n \"rectifier\": {\n \"title\": \"Rectifier\",\n \"description\": \"Automatically fix API request compatibility issues\",\n \"enabled\": \"Enable Rectifier\",\n \"enabledDescription\": \"Master switch, all rectification features will be disabled when turned off\",\n \"requestGroup\": \"Request Rectification\",\n \"responseGroup\": \"Response Rectification\",\n \"thinkingSignature\": \"Thinking Signature Rectification\",\n \"thinkingSignatureDescription\": \"When an Anthropic-type provider returns thinking signature incompatibility or illegal request errors, automatically removes incompatible thinking-related blocks and retries once with the same provider\",\n \"thinkingBudget\": \"Thinking Budget Rectification\",\n \"thinkingBudgetDescription\": \"When an Anthropic-type provider returns budget_tokens constraint errors (such as at least 1024), automatically normalizes thinking to enabled, sets thinking budget to 32000, and raises max_tokens to 64000 if needed, then retries once\",\n \"mediaFallback\": \"Unsupported Image Fallback\",\n \"mediaFallbackDescription\": \"When a provider does not support image input (it declares text-only capability, or the upstream returns an unsupported-image error), automatically replaces image blocks with an [Unsupported Image] marker so the conversation is not interrupted\",\n \"mediaHeuristic\": \"Text-Only Model Preflight\",\n \"mediaHeuristicDescription\": \"Before sending, strips images for models in the built-in confirmed text-only registry. Turning this off only disables the proxy's registry-based preflight; declared text-only handling and upstream-error fallback remain, and Codex model-catalog capability declarations are unchanged\"\n },\n \"optimizer\": {\n \"title\": \"Bedrock Request Optimizer\",\n \"description\": \"Automatically optimize Thinking and Cache configuration before sending requests (only applies to Bedrock providers)\",\n \"enabled\": \"Enable Optimizer\",\n \"thinkingOptimizer\": \"Thinking Optimization\",\n \"thinkingOptimizerDescription\": \"Automatically enable Adaptive Thinking for Opus/Sonnet, and inject Extended Thinking for legacy models\",\n \"cacheInjection\": \"Cache Injection\",\n \"cacheInjectionDescription\": \"Automatically inject standard 5-minute cache breakpoints at key positions to reduce duplicate token billing\"\n },\n \"logConfig\": {\n \"title\": \"Log Management\",\n \"description\": \"Control log output level\",\n \"enabled\": \"Enable Logging\",\n \"enabledDescription\": \"Master switch, all logging will be disabled when turned off\",\n \"level\": \"Log Level\",\n \"levelDescription\": \"Set the minimum log level to output\",\n \"levels\": {\n \"error\": \"Error\",\n \"warn\": \"Warning\",\n \"info\": \"Info\",\n \"debug\": \"Debug\",\n \"trace\": \"Trace\"\n },\n \"levelHint\": \"Log level descriptions:\",\n \"levelDesc\": {\n \"error\": \"Critical errors only\",\n \"warn\": \"Errors + warnings\",\n \"info\": \"General operation info (default)\",\n \"debug\": \"Detailed info including SSE stream and request/response\",\n \"trace\": \"All logs, most verbose\"\n }\n }\n },\n \"language\": \"Language\",\n \"languageHint\": \"Preview interface language immediately after switching, takes effect permanently after saving.\",\n \"theme\": \"Theme\",\n \"themeHint\": \"Choose the appearance theme for the app, takes effect immediately.\",\n \"themeLight\": \"Light\",\n \"themeDark\": \"Dark\",\n \"themeSystem\": \"System\",\n \"importExport\": \"SQL Import/Export\",\n \"importExportHint\": \"Import or export database SQL backups for migration or restore (import supports only backups exported by CC Switch).\",\n \"exportConfig\": \"Export SQL Backup\",\n \"selectConfigFile\": \"Select SQL File\",\n \"noFileSelected\": \"No configuration file selected.\",\n \"import\": \"Import\",\n \"importing\": \"Importing...\",\n \"importSuccess\": \"Import Successful!\",\n \"importFailed\": \"Import Failed\",\n \"syncLiveFailed\": \"Imported, but failed to sync to the current provider. Please reselect the provider manually.\",\n \"importPartialSuccess\": \"Config imported, but failed to sync to the current provider.\",\n \"importPartialHint\": \"Please manually reselect the provider to refresh the live configuration.\",\n \"configExported\": \"Config exported to:\",\n \"exportFailed\": \"Export failed\",\n \"selectFileFailed\": \"Please choose a valid SQL backup file\",\n \"configCorrupted\": \"SQL file may be corrupted or invalid\",\n \"backupId\": \"Backup ID\",\n \"backupManager\": {\n \"title\": \"Database Backups\",\n \"description\": \"Automatic database snapshots for restoring to a previous state\",\n \"empty\": \"No backups yet\",\n \"restore\": \"Restore\",\n \"restoring\": \"Restoring...\",\n \"confirmTitle\": \"Confirm Restore\",\n \"confirmMessage\": \"Restoring this backup will overwrite the current database. A safety backup will be created first.\",\n \"restoreSuccess\": \"Restore successful! Safety backup created\",\n \"restoreFailed\": \"Restore failed\",\n \"safetyBackupId\": \"Safety Backup ID\",\n \"intervalLabel\": \"Auto-backup Interval\",\n \"retainLabel\": \"Backup Retention\",\n \"intervalDisabled\": \"Disabled\",\n \"intervalHours\": \"{{hours}} hours\",\n \"intervalDays\": \"{{days}} days\",\n \"rename\": \"Rename\",\n \"renameSuccess\": \"Backup renamed\",\n \"renameFailed\": \"Rename failed\",\n \"namePlaceholder\": \"Enter new name\",\n \"createBackup\": \"Backup Now\",\n \"creating\": \"Backing up...\",\n \"createSuccess\": \"Backup created successfully\",\n \"createFailed\": \"Backup failed\",\n \"delete\": \"Delete\",\n \"deleting\": \"Deleting...\",\n \"deleteSuccess\": \"Backup deleted\",\n \"deleteFailed\": \"Delete failed\",\n \"deleteConfirmTitle\": \"Confirm Delete\",\n \"deleteConfirmMessage\": \"This backup will be permanently deleted. This action cannot be undone.\"\n },\n \"webdavSync\": {\n \"title\": \"WebDAV Cloud Sync\",\n \"description\": \"Sync database and skill configurations across devices via WebDAV.\",\n \"baseUrl\": \"WebDAV Server URL\",\n \"baseUrlPlaceholder\": \"https://dav.example.com/dav/\",\n \"username\": \"WebDAV Account\",\n \"usernamePlaceholder\": \"Email or username\",\n \"password\": \"WebDAV Password\",\n \"passwordPlaceholder\": \"App password\",\n \"remoteRoot\": \"Remote Root Directory\",\n \"profile\": \"Sync Profile Name\",\n \"autoSync\": \"Auto Sync\",\n \"autoSyncHint\": \"When enabled, each database change triggers an automatic WebDAV upload.\",\n \"test\": \"Test Connection\",\n \"testing\": \"Testing...\",\n \"testSuccess\": \"Connection successful\",\n \"testFailed\": \"Connection failed: {{error}}\",\n \"save\": \"Save Config\",\n \"saving\": \"Saving...\",\n \"saveFailed\": \"Failed to save config: {{error}}\",\n \"upload\": \"Upload to Cloud\",\n \"uploading\": \"Uploading...\",\n \"uploadSuccess\": \"Uploaded to WebDAV\",\n \"uploadFailed\": \"Upload failed: {{error}}\",\n \"autoSyncFailedToast\": \"Auto sync failed: {{error}}\",\n \"download\": \"Download from Cloud\",\n \"downloading\": \"Downloading...\",\n \"downloadSuccess\": \"Downloaded and restored from WebDAV\",\n \"downloadFailed\": \"Download failed: {{error}}\",\n \"lastSync\": \"Last sync: {{time}}\",\n \"autoSyncLastErrorTitle\": \"Last auto sync failed\",\n \"autoSyncLastErrorHint\": \"Please check network or WebDAV settings. Auto sync will retry on future changes.\",\n \"missingUrl\": \"Please enter the WebDAV server URL\",\n \"presets\": {\n \"label\": \"Provider\",\n \"jianguoyun\": \"Jianguoyun\",\n \"jianguoyunHint\": \"Generate an \\\"App Password\\\" in Jianguoyun security settings. Do not use your login password.\",\n \"nextcloud\": \"Nextcloud\",\n \"nextcloudHint\": \"Replace your-server with your Nextcloud domain and USERNAME with your username.\",\n \"synology\": \"Synology NAS\",\n \"synologyHint\": \"Install and enable the WebDAV Server package in Synology Package Center first.\",\n \"custom\": \"Custom\"\n },\n \"remoteRootDefault\": \"Default: cc-switch-sync\",\n \"profileDefault\": \"Default: default\",\n \"saveAndTestSuccess\": \"Config saved, connection OK\",\n \"saveAndTestFailed\": \"Config saved, but connection test failed: {{error}}\",\n \"noRemoteData\": \"No sync data found on the remote server\",\n \"incompatibleVersion\": \"Remote data is incompatible (protocol v{{protocolVersion}}, database {{dbCompatVersion}}). This client supports protocol v2 / db-v6.\",\n \"unsaved\": \"Unsaved\",\n \"saved\": \"Saved\",\n \"unsavedChanges\": \"Please save config first\",\n \"saveBeforeSync\": \"Save configuration first to enable upload/download.\",\n \"fetchingRemote\": \"Fetching remote info...\",\n \"fetchRemoteFailed\": \"Failed to fetch remote info. Please check configuration and network.\",\n \"confirmDownload\": {\n \"title\": \"Restore from Cloud\",\n \"deviceName\": \"Uploaded by\",\n \"createdAt\": \"Uploaded at\",\n \"path\": \"Remote path\",\n \"dbCompat\": \"DB compatibility\",\n \"artifacts\": \"Contents\",\n \"legacyNotice\": \"A legacy remote path was detected. After restoring, the next upload will write to the new v2/db-v6 path.\",\n \"warning\": \"This will overwrite all local data and skill configurations\",\n \"confirm\": \"Confirm Restore\"\n },\n \"confirmUpload\": {\n \"title\": \"Upload to Cloud\",\n \"content\": \"The following will be synced to the WebDAV server:\",\n \"dbItem\": \"Database (all provider configs and data)\",\n \"skillsItem\": \"Skills (all custom skills)\",\n \"targetPath\": \"Target path\",\n \"existingData\": \"Existing cloud data\",\n \"deviceName\": \"Uploaded by\",\n \"createdAt\": \"Uploaded at\",\n \"path\": \"Remote path\",\n \"dbCompat\": \"DB compatibility\",\n \"warning\": \"This will overwrite existing sync data on the remote server\",\n \"legacyNotice\": \"Legacy remote data was detected. This upload will write to the new v2/db-v6 path and will not overwrite the legacy path.\",\n \"confirm\": \"Confirm Upload\"\n }\n },\n \"syncType\": {\n \"label\": \"Sync Type\",\n \"webdav\": \"WebDAV\",\n \"s3\": \"S3 Compatible\"\n },\n \"s3Sync\": {\n \"presets\": {\n \"label\": \"Provider\",\n \"awsS3\": \"AWS S3\",\n \"awsS3Hint\": \"Use IAM Access Key with s3:PutObject/GetObject/HeadObject permissions. Region: us-east-1, ap-northeast-1, etc.\",\n \"minio\": \"MinIO\",\n \"minioHint\": \"Custom endpoint required, e.g. http://minio.local:9000. Region can be 'us-east-1'.\",\n \"r2\": \"Cloudflare R2\",\n \"r2Hint\": \"Endpoint: https://<account_id>.r2.cloudflarestorage.com. Region: 'auto'.\",\n \"oss\": \"Alibaba Cloud OSS\",\n \"ossHint\": \"Endpoint: https://oss-<region>.aliyuncs.com. Region: cn-hangzhou, cn-shanghai, etc.\",\n \"cos\": \"Tencent Cloud COS\",\n \"cosHint\": \"Endpoint: https://cos.<region>.myqcloud.com. Region: ap-guangzhou, ap-beijing, etc.\",\n \"obs\": \"Huawei OBS\",\n \"obsHint\": \"Endpoint: https://obs.<region>.myhuaweicloud.com. Region: cn-north-4, cn-east-3, etc.\",\n \"custom\": \"S3 Compatible (Custom)\",\n \"customHint\": \"Any S3-compatible service. Custom endpoint required.\"\n },\n \"region\": \"Region\",\n \"regionPlaceholder\": \"e.g. us-east-1\",\n \"bucket\": \"Bucket\",\n \"bucketPlaceholder\": \"e.g. my-cc-switch-bucket\",\n \"accessKeyId\": \"Access Key ID\",\n \"accessKeyIdPlaceholder\": \"e.g. AKIA...\",\n \"secretAccessKey\": \"Secret Access Key\",\n \"secretAccessKeyPlaceholder\": \"Leave unchanged to keep the existing secret\",\n \"endpoint\": \"Endpoint (optional for AWS)\",\n \"endpointHint\": \"Required for most S3-compatible services\",\n \"endpointPlaceholder\": \"e.g. https://s3-compatible.example.com\",\n \"remoteRoot\": \"Remote Root Directory\",\n \"remoteRootDefault\": \"Default: cc-switch-sync\",\n \"profile\": \"Sync Profile Name\",\n \"profileDefault\": \"Default: default\",\n \"autoSync\": \"Auto Sync\",\n \"autoSyncHint\": \"When enabled, each database change triggers an automatic S3 upload.\",\n \"enabled\": \"Enable S3 Sync\",\n \"enabledHint\": \"Turn on S3 sync for manual and automatic cloud operations.\",\n \"mutualExclusionTitle\": \"Switch Sync Type\",\n \"mutualExclusionMessage\": \"Enabling S3 sync will disable the current WebDAV sync. Continue?\",\n \"mutualExclusionMessageReverse\": \"Enabling WebDAV sync will disable the current S3 sync. Continue?\",\n \"mutualExclusionFailed\": \"Failed to switch sync type: {{error}}\",\n \"testConnection\": \"Test Connection\",\n \"connectionOk\": \"S3 connection successful\",\n \"test\": \"Test Connection\",\n \"testing\": \"Testing...\",\n \"testSuccess\": \"S3 connection successful\",\n \"testFailed\": \"S3 connection failed: {{error}}\",\n \"save\": \"Save Config\",\n \"saving\": \"Saving...\",\n \"saveFailed\": \"Failed to save config: {{error}}\",\n \"saveAndTestSuccess\": \"Config saved, S3 connection OK\",\n \"saveAndTestFailed\": \"Config saved, but S3 connection test failed: {{error}}\",\n \"upload\": \"Upload to Cloud\",\n \"uploading\": \"Uploading...\",\n \"uploadSuccess\": \"Uploaded to S3\",\n \"uploadFailed\": \"S3 upload failed: {{error}}\",\n \"autoSyncFailedToast\": \"S3 auto sync failed: {{error}}\",\n \"download\": \"Download from Cloud\",\n \"downloading\": \"Downloading...\",\n \"downloadSuccess\": \"Downloaded and restored from S3\",\n \"downloadFailed\": \"S3 download failed: {{error}}\",\n \"lastSync\": \"Last sync: {{time}}\",\n \"autoSyncLastErrorTitle\": \"Last S3 auto sync failed\",\n \"autoSyncLastErrorHint\": \"Please check network or S3 settings. Auto sync will retry on future changes.\",\n \"missingBucket\": \"Please enter the S3 bucket\",\n \"noRemoteData\": \"No sync data found in the S3 bucket\",\n \"incompatibleVersion\": \"Remote data is incompatible (version {{version}}). This client supports protocol v2 / db-v6.\",\n \"unsaved\": \"Unsaved\",\n \"saved\": \"Saved\",\n \"unsavedChanges\": \"Please save config first\",\n \"saveBeforeSync\": \"Save configuration first to enable upload/download.\",\n \"fetchingRemote\": \"Fetching remote info...\",\n \"fetchRemoteFailed\": \"Failed to fetch remote info. Please check configuration and network.\",\n \"notConfigured\": \"S3 sync is not configured\",\n \"disabled\": \"S3 sync is disabled\",\n \"confirmDownload\": {\n \"title\": \"Restore from S3\",\n \"deviceName\": \"Uploaded by\",\n \"createdAt\": \"Uploaded at\",\n \"artifacts\": \"Contents\",\n \"warning\": \"This will overwrite all local data and skill configurations\",\n \"confirm\": \"Confirm Restore\"\n },\n \"confirmUpload\": {\n \"title\": \"Upload to S3\",\n \"content\": \"The following will be synced to the S3 bucket:\",\n \"dbItem\": \"Database (all provider configs and data)\",\n \"skillsItem\": \"Skills (all custom skills)\",\n \"targetPath\": \"Target object path\",\n \"existingData\": \"Existing cloud data\",\n \"deviceName\": \"Uploaded by\",\n \"createdAt\": \"Uploaded at\",\n \"warning\": \"This will overwrite existing sync data at the S3 path\",\n \"confirm\": \"Confirm Upload\"\n }\n },\n \"autoReload\": \"Data refreshed\",\n \"languageOptionChinese\": \"简体中文\",\n \"languageOptionTraditionalChinese\": \"繁體中文\",\n \"languageOptionEnglish\": \"English\",\n \"languageOptionJapanese\": \"日本語\",\n \"windowBehavior\": \"Window Behavior\",\n \"windowBehaviorHint\": \"Configure window minimize and Claude plugin integration policies.\",\n \"launchOnStartup\": \"Launch on Startup\",\n \"launchOnStartupDescription\": \"Automatically run CC Switch when system starts\",\n \"silentStartup\": \"Silent Startup\",\n \"silentStartupDescription\": \"Start in background mode without showing main window\",\n \"autoLaunchFailed\": \"Failed to set auto-launch\",\n \"minimizeToTray\": \"Minimize to tray on close\",\n \"minimizeToTrayDescription\": \"When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.\",\n \"useAppWindowControls\": \"Enable app-level window controls\",\n \"useAppWindowControlsDescription\": \"Use built-in minimize, maximize/restore, and close buttons in the app header.\",\n \"enableClaudePluginIntegration\": \"Apply to Claude Code extension\",\n \"enableClaudePluginIntegrationDescription\": \"When enabled, the VS Code Claude Code extension provider will switch with this app\",\n \"skipClaudeOnboarding\": \"Skip Claude Code first-run confirmation\",\n \"skipClaudeOnboardingDescription\": \"When enabled, Claude Code will skip the first-run confirmation\",\n \"codexAuth\": \"Codex App Enhancements\",\n \"preserveCodexOfficialAuthOnSwitch\": \"Keep official login for direct switches\",\n \"preserveCodexOfficialAuthOnSwitchDescription\": \"Controls third-party switches when local routing is off. Takeover routing always preserves the Codex official login.\",\n \"unifyCodexSessionHistory\": \"Unified Codex session history\",\n \"unifyCodexSessionHistoryDescription\": \"When enabled, the official subscription runs under the shared \\\"custom\\\" provider id so official and third-party sessions appear in one history list, optionally migrating existing official sessions in (backed up first). When turning it off, the migrated sessions can be restored from backup. Note: resuming an old session across providers may fail because its encrypted_content reasoning can only be decrypted by the backend that created it.\",\n \"unifyCodexHistoryRestoreCompleted\": \"Official session history restored from backup ({{files}} session files, {{rows}} index rows)\",\n \"unifyCodexHistoryRestoreFailed\": \"Failed to restore official session history, please try again\",\n \"unifyCodexHistoryRestoreNothing\": \"No restorable migration backup for the current Codex directory\",\n \"unifyCodexHistoryRestoreSkippedToggleOn\": \"Unified session history was re-enabled; restore skipped\",\n \"appVisibility\": {\n \"title\": \"Homepage Display\",\n \"description\": \"Choose which apps to show on the homepage\",\n \"claudeDesc\": \"Anthropic Claude Code CLI\",\n \"codexDesc\": \"OpenAI Codex CLI\",\n \"geminiDesc\": \"Google Gemini CLI\",\n \"opencodeDesc\": \"OpenCode CLI\",\n \"showProfileSwitcher\": \"Show project switcher\",\n \"showProfileSwitcherDescription\": \"Show the project switcher in the main page header\"\n },\n \"skillStorage\": {\n \"title\": \"Skill Storage Location\",\n \"description\": \"Choose where CC Switch stores the master copies of your skills\",\n \"ccSwitch\": \"CC Switch\",\n \"unified\": \"~/.agents/skills\",\n \"ccSwitchHint\": \"Skills are stored in ~/.cc-switch/skills/ and synced to each app via symlink or copy.\",\n \"unifiedHint\": \"Skills are stored in ~/.agents/skills/, the Agent Skills open standard. Compatible tools (Claude Code, Codex, Gemini CLI, etc.) discover skills here natively.\",\n \"confirmTitle\": \"Migrate Skill Storage\",\n \"confirmMessage\": \"{{count}} skill(s) will be moved to the new location. Continue?\",\n \"migrationSuccess\": \"Successfully migrated {{count}} skill(s)\",\n \"migrationPartial\": \"Migrated {{migrated}} skill(s), {{errors}} error(s). Check logs for details.\"\n },\n \"skillSync\": {\n \"title\": \"Skill Sync Method\",\n \"description\": \"Choose how to sync Skills files\",\n \"symlink\": \"Symlink\",\n \"copy\": \"Copy Files\",\n \"symlinkHint\": \"Symlinks save disk space and enable real-time sync. Note: May require admin privileges or Developer Mode on Windows\"\n },\n \"terminal\": {\n \"title\": \"Preferred Terminal\",\n \"description\": \"Choose which terminal app to use when clicking the terminal button\",\n \"fallbackHint\": \"If the selected terminal is unavailable, the system default will be used\",\n \"options\": {\n \"macos\": {\n \"terminal\": \"Terminal.app\",\n \"iterm2\": \"iTerm2\",\n \"alacritty\": \"Alacritty\",\n \"kitty\": \"Kitty\",\n \"ghostty\": \"Ghostty\",\n \"wezterm\": \"WezTerm\",\n \"kaku\": \"Kaku\",\n \"warp\": \"Warp\"\n },\n \"windows\": {\n \"cmd\": \"Command Prompt\",\n \"powershell\": \"PowerShell\",\n \"wt\": \"Windows Terminal\"\n },\n \"linux\": {\n \"gnomeTerminal\": \"GNOME Terminal\",\n \"konsole\": \"Konsole\",\n \"xfce4Terminal\": \"Xfce4 Terminal\",\n \"alacritty\": \"Alacritty\",\n \"kitty\": \"Kitty\",\n \"ghostty\": \"Ghostty\"\n }\n }\n },\n \"configDirectoryOverride\": \"Configuration Directory Override (Advanced)\",\n \"configDirectoryDescription\": \"When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.\",\n \"appConfigDir\": \"CC Switch Configuration Directory\",\n \"appConfigDirDescription\": \"Customize the storage location for CC Switch configuration (point to cloud sync folder to enable config sync)\",\n \"browsePlaceholderApp\": \"e.g., C:\\\\Users\\\\Administrator\\\\.cc-switch\",\n \"claudeConfigDir\": \"Claude Code Configuration Directory\",\n \"claudeConfigDirDescription\": \"Override Claude configuration directory (settings.json). Custom directories store .claude.json (MCP) inside the directory; the default ~/.claude directory keeps Claude Code's split ~/.claude.json path.\",\n \"codexConfigDir\": \"Codex Configuration Directory\",\n \"codexConfigDirDescription\": \"Override Codex configuration directory.\",\n \"geminiConfigDir\": \"Gemini Configuration Directory\",\n \"geminiConfigDirDescription\": \"Override Gemini configuration directory (.env).\",\n \"opencodeConfigDir\": \"OpenCode Configuration Directory\",\n \"opencodeConfigDirDescription\": \"Override OpenCode configuration directory (opencode.json).\",\n \"openclawConfigDir\": \"OpenClaw Configuration Directory\",\n \"openclawConfigDirDescription\": \"Override OpenClaw configuration directory (openclaw.json).\",\n \"hermesConfigDir\": \"Hermes Configuration Directory\",\n \"hermesConfigDirDescription\": \"Override Hermes configuration directory (config.yaml).\",\n \"browsePlaceholderClaude\": \"e.g., /home/<your-username>/.claude\",\n \"browsePlaceholderCodex\": \"e.g., /home/<your-username>/.codex\",\n \"browsePlaceholderGemini\": \"e.g., /home/<your-username>/.gemini\",\n \"browsePlaceholderOpencode\": \"e.g., /home/<your-username>/.config/opencode\",\n \"browsePlaceholderOpenclaw\": \"e.g., /home/<your-username>/.openclaw\",\n \"browsePlaceholderHermes\": \"e.g., /home/<your-username>/.hermes\",\n \"browseDirectory\": \"Browse Directory\",\n \"resetDefault\": \"Reset to default directory (takes effect after saving)\",\n \"checkForUpdates\": \"Check for Updates\",\n \"updateTo\": \"Update to v{{version}}\",\n \"updating\": \"Updating...\",\n \"checking\": \"Checking...\",\n \"upToDate\": \"Up to Date\",\n \"aboutHint\": \"View version information and update status.\",\n \"portableMode\": \"Portable mode: updates require manual download.\",\n \"updateAvailable\": \"New version available: {{version}}\",\n \"updateBadge\": \"Update available\",\n \"updateFailed\": \"Update installation failed, attempted to open download page.\",\n \"checkUpdateFailed\": \"Failed to check for updates, please try again later.\",\n \"openReleaseNotesFailed\": \"Failed to open release notes\",\n \"releaseNotes\": \"Release Notes\",\n \"viewReleaseNotes\": \"View release notes for this version\",\n \"viewCurrentReleaseNotes\": \"View current version release notes\",\n \"officialWebsite\": \"Official Website\",\n \"github\": \"GitHub\",\n \"manualInstallCommands\": \"Manual Install Commands\",\n \"oneClickInstallHint\": \"Install or upgrade Claude Code / Codex / Gemini CLI / OpenCode / OpenClaw / Hermes\",\n \"localEnvCheck\": \"Local environment check\",\n \"updateAllTools\": \"Update All ({{count}})\",\n \"currentVersion\": \"Current Version\",\n \"latestVersion\": \"Latest Version\",\n \"updateAvailableShort\": \"Update\",\n \"toolInstall\": \"Install\",\n \"toolUpdate\": \"Update\",\n \"toolReady\": \"Ready\",\n \"toolActionDone\": \"{{action}} completed for {{count}} tool(s)\",\n \"toolActionPartial\": \"{{action}} succeeded for {{succeeded}}, failed for {{failed}}\",\n \"toolActionFailed\": \"Install/update command failed\",\n \"toolNotRunnable\": \"Installed but not runnable (no version detected)\",\n \"toolActionVersionUnchangedTitle\": \"Version did not change\",\n \"toolActionVersionUnchanged\": \"Still on {{version}} (latest: {{latest}}). The updater may have reported success without applying the update.\",\n \"toolActionInstalledNotRunnable\": \"Installed, but it can't run in the current environment — please check\",\n \"installedNotRunnable\": \"Installed · can't run\",\n \"toolCheckEnv\": \"Check environment\",\n \"toolDiagnose\": \"Diagnose installs\",\n \"toolDiagnosing\": \"Diagnosing…\",\n \"toolConflictTitle\": \"Multiple installations detected\",\n \"toolConflictHint\": \"The command line uses the one marked Default; an upgrade may have written to another.\",\n \"toolConflictDefault\": \"Default\",\n \"toolConflictNotRunnable\": \"can't run\",\n \"toolDiagnoseNoConflict\": \"No conflicting installations found\",\n \"toolDiagnoseFailed\": \"Diagnosis failed\",\n \"toolUpgradeConfirmTitle\": \"Confirm upgrade target\",\n \"toolUpgradeConfirmHint\": \"Multiple installations detected. This upgrade won't update all of them; refer to each tool below for what it actually targets.\",\n \"toolUpgradeWillRun\": \"Will run:\",\n \"toolUpgradeConfirmBtn\": \"Confirm upgrade\",\n \"toolUpgradeUnanchoredHint\": \"Can't determine which install your command line uses; will run the default upgrade command (may install to the first npm on PATH).\",\n \"envBadge\": {\n \"wsl\": \"WSL\",\n \"windows\": \"Win\",\n \"macos\": \"macOS\",\n \"linux\": \"Linux\"\n },\n \"wslShell\": \"Shell\",\n \"wslShellFlag\": \"Flag\",\n \"installCommandsCopied\": \"Install commands copied\",\n \"installCommandsCopyFailed\": \"Copy failed, please copy manually.\",\n \"importFailedError\": \"Import config failed: {{message}}\",\n \"exportFailedError\": \"Export config failed:\",\n \"restartRequired\": \"Restart Required\",\n \"restartRequiredMessage\": \"Modifying the CC Switch configuration directory requires restarting the application to take effect. Restart now?\",\n \"restartNow\": \"Restart Now\",\n \"restartLater\": \"Restart Later\",\n \"restartFailed\": \"Application restart failed, please manually close and reopen.\",\n \"devModeRestartHint\": \"Dev Mode: Automatic restart not supported, please manually restart the application.\",\n \"saving\": \"Saving...\",\n \"globalProxy\": {\n \"label\": \"Global Proxy\",\n \"hint\": \"Proxy all requests (API, Skills download, etc.). When local routing is enabled, app traffic is also routed through this proxy. Leave empty for direct connection.\",\n \"username\": \"Username (optional)\",\n \"password\": \"Password (optional)\",\n \"test\": \"Test Connection\",\n \"scan\": \"Scan Local Proxies\",\n \"clear\": \"Clear\",\n \"scanFailed\": \"Scan failed: {{error}}\",\n \"saved\": \"Proxy settings saved\",\n \"saveFailed\": \"Save failed: {{error}}\",\n \"testSuccess\": \"Connected! Latency {{latency}}ms\",\n \"testFailed\": \"Connection failed: {{error}}\",\n \"pricingDefaultsTitle\": \"Pricing Defaults\",\n \"pricingDefaultsDescription\": \"Set the default multiplier and pricing model source per app.\",\n \"pricingAppLabel\": \"App\",\n \"defaultCostMultiplierLabel\": \"Default Multiplier\",\n \"defaultCostMultiplierHint\": \"Multiplier for cost calculation, decimals supported.\",\n \"pricingModelSourceLabel\": \"Pricing Model Source\",\n \"pricingModelSourceRequest\": \"Request model\",\n \"pricingModelSourceResponse\": \"Response model\",\n \"pricingSave\": \"Save Pricing Defaults\",\n \"pricingSaved\": \"Pricing defaults saved\",\n \"pricingSaveFailed\": \"Failed to save pricing defaults: {{error}}\",\n \"pricingLoadFailed\": \"Failed to load pricing defaults: {{error}}\",\n \"defaultCostMultiplierRequired\": \"Default multiplier is required\",\n \"defaultCostMultiplierInvalid\": \"Invalid multiplier format\"\n },\n \"saveFailedGeneric\": \"Save failed, please try again\"\n },\n \"apps\": {\n \"claude\": \"Claude\",\n \"claudeCode\": \"Claude Code\",\n \"claudeDesktop\": \"Claude Desktop\",\n \"claude-desktop\": \"Claude Desktop\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\",\n \"openclaw\": \"OpenClaw\",\n \"hermes\": \"Hermes\",\n \"grok\": \"Grok Build\"\n },\n \"sessionManager\": {\n \"title\": \"Session Manager\",\n \"subtitle\": \"Manage Claude Code, Codex, OpenCode, OpenClaw, Hermes and Gemini CLI sessions\",\n \"searchPlaceholder\": \"Search by content, directory, or ID\",\n \"searchSessions\": \"Search sessions\",\n \"providerFilterAll\": \"All\",\n \"providerFilterTooltip\": \"Provider filter\",\n \"sessionList\": \"Sessions\",\n \"viewModeTooltip\": \"View mode\",\n \"viewModeFlat\": \"List\",\n \"viewModeGrouped\": \"Grouped\",\n \"unknownDirectory\": \"Unknown directory\",\n \"toggleProviderGroup\": \"Expand or collapse {{provider}} provider group\",\n \"toggleDirectoryGroup\": \"Expand or collapse {{directory}} directory group\",\n \"collapseAllGroups\": \"Collapse all\",\n \"selectProviderGroupForBatch\": \"Select sessions in {{provider}} provider group\",\n \"selectDirectoryGroupForBatch\": \"Select sessions in {{directory}} directory group\",\n \"manageBatchTooltip\": \"Enter batch management\",\n \"exitBatchModeTooltip\": \"Exit batch management\",\n \"batchModeHint\": \"Select sessions to delete\",\n \"selectForBatch\": \"Select session\",\n \"selectedCount\": \"{{count}} selected\",\n \"selectAllFiltered\": \"Select all\",\n \"clearFilteredSelection\": \"Clear selection\",\n \"clearSelection\": \"Clear\",\n \"deleteSelected\": \"Delete\",\n \"batchDeleting\": \"Deleting...\",\n \"loadingSessions\": \"Loading sessions...\",\n \"noSessions\": \"No sessions found\",\n \"selectSession\": \"Select a session to view details\",\n \"noSummary\": \"No summary available\",\n \"lastActive\": \"Last active\",\n \"projectDir\": \"Project directory\",\n \"sourcePath\": \"Source file\",\n \"copyResumeCommand\": \"Copy resume command\",\n \"resumeCommandCopied\": \"Resume command copied\",\n \"openInTerminal\": \"Resume in terminal\",\n \"terminalTargetTerminal\": \"Terminal\",\n \"terminalTargetKitty\": \"kitty\",\n \"terminalTargetCopy\": \"Copy only\",\n \"terminalLaunched\": \"Terminal launched\",\n \"openFailed\": \"Failed to launch terminal\",\n \"resumeFallbackCopied\": \"Resume command copied for manual use\",\n \"copyProjectDir\": \"Copy directory\",\n \"projectDirCopied\": \"Directory copied\",\n \"copySourcePath\": \"Copy source file\",\n \"sourcePathCopied\": \"Source file copied\",\n \"delete\": \"Delete session\",\n \"deleting\": \"Deleting...\",\n \"deleteTooltip\": \"Permanently delete this local session record\",\n \"deleteConfirmTitle\": \"Delete session\",\n \"deleteConfirmMessage\": \"This will permanently delete the local session \\\"{{title}}\\\"\\nSession ID: {{sessionId}}\\n\\nThis action cannot be undone.\",\n \"deleteConfirmAction\": \"Delete session\",\n \"sessionDeleted\": \"Session deleted\",\n \"deleteFailed\": \"Failed to delete session: {{error}}\",\n \"batchDeleteConfirmTitle\": \"Delete selected sessions\",\n \"batchDeleteConfirmMessage\": \"This will permanently delete {{count}} selected local sessions.\\n\\nThis action cannot be undone.\",\n \"batchDeleteConfirmAction\": \"Delete selected\",\n \"batchDeleteSuccess\": \"Deleted {{count}} sessions\",\n \"batchDeleteFailed\": \"{{failed}} sessions could not be deleted\",\n \"batchDeleteRequestFailed\": \"Batch delete failed. Please try again later.\",\n \"loadingMessages\": \"Loading transcript...\",\n \"emptySession\": \"No messages available\",\n \"clickToCopyPath\": \"Click to copy path\",\n \"tocTitle\": \"Contents\",\n \"justNow\": \"Just now\",\n \"minutesAgo\": \"{{count}} min ago\",\n \"hoursAgo\": \"{{count}} hr ago\",\n \"daysAgo\": \"{{count}} days ago\",\n \"roleUser\": \"User\",\n \"roleSystem\": \"System\",\n \"roleTool\": \"Tool\",\n \"resume\": \"Resume Session\",\n \"resumeTooltip\": \"Resume this session in terminal\",\n \"noResumeCommand\": \"This session cannot be resumed\",\n \"copyCommand\": \"Copy Command\",\n \"copyMessage\": \"Copy Message\",\n \"messageCopied\": \"Message copied\",\n \"conversationHistory\": \"Conversation History\",\n \"expandContent\": \"Expand full content\",\n \"collapseContent\": \"Collapse\"\n },\n \"console\": {\n \"providerSwitchReceived\": \"Received provider switch event:\",\n \"setupListenerFailed\": \"Failed to setup provider switch listener:\",\n \"updateProviderFailed\": \"Update provider failed:\",\n \"autoImportFailed\": \"Auto import default configuration failed:\",\n \"openLinkFailed\": \"Failed to open link:\",\n \"getVersionFailed\": \"Failed to get version info:\",\n \"loadSettingsFailed\": \"Failed to load settings:\",\n \"getConfigPathFailed\": \"Failed to get config path:\",\n \"getConfigDirFailed\": \"Failed to get config directory:\",\n \"detectPortableFailed\": \"Failed to detect portable mode:\",\n \"saveSettingsFailed\": \"Failed to save settings:\",\n \"updateFailed\": \"Update failed:\",\n \"checkUpdateFailed\": \"Check for updates failed:\",\n \"openConfigFolderFailed\": \"Failed to open config folder:\",\n \"selectConfigDirFailed\": \"Failed to select config directory:\",\n \"getDefaultConfigDirFailed\": \"Failed to get default config directory:\",\n \"openReleaseNotesFailed\": \"Failed to open release notes:\"\n },\n \"providerForm\": {\n \"supplierName\": \"Provider Name\",\n \"supplierNameRequired\": \"Provider Name *\",\n \"supplierNamePlaceholder\": \"e.g., Anthropic Official\",\n \"websiteUrl\": \"Website URL\",\n \"websiteUrlPlaceholder\": \"https://example.com (optional)\",\n \"apiEndpoint\": \"API Endpoint\",\n \"apiEndpointPlaceholder\": \"https://your-api-endpoint.com\",\n \"codexApiEndpointPlaceholder\": \"https://your-api-endpoint.com/v1\",\n \"manageAndTest\": \"Manage & Test\",\n \"configContent\": \"Config Content\",\n \"officialNoApiKey\": \"Official login does not require API Key, save directly\",\n \"codexOfficialNoApiKey\": \"Official does not require API Key, save directly\",\n \"codexApiKeyAutoFill\": \"Just fill in here, auth.json below will be auto-filled\",\n \"apiKeyAutoFill\": \"Just fill in here, config below will be auto-filled\",\n \"cnOfficialApiKeyHint\": \"💡 Only need to fill in API Key, endpoint is preset\",\n \"aggregatorApiKeyHint\": \"💡 Only need to fill in API Key, endpoint is preset\",\n \"thirdPartyApiKeyHint\": \"💡 Only need to fill in API Key, endpoint is preset\",\n \"customApiKeyHint\": \"💡 Custom configuration requires manually filling all necessary fields\",\n \"omoHint\": \"💡 OMO config manages Agent model assignments and supports both oh-my-openagent.jsonc and oh-my-opencode.jsonc\",\n \"officialHint\": \"💡 Official provider uses browser login, no API Key needed\",\n \"getApiKey\": \"Get API Key\",\n \"partnerPromotion\": {\n \"sudocode\": \"With one SudoCode key, Claude Code and Claude Desktop use Claude Opus 4.8, while Codex uses GPT-5.6. Sign up, join QQ group 726213516, and contact the group owner to claim CNY ¥10 in trial credit.\",\n \"code0\": \"code0.ai is an AI coding service platform for developers, supporting Claude Code, Codex, and Gemini. Exclusive for CC Switch users: contact support via the official website to claim free trial credits!\",\n \"nekocode\": \"NekoCode gives developers a stable, efficient, and reliable API relay for Claude, Codex, and other AI models, with transparent pay-as-you-go pricing. Exclusive 10% off for CC Switch users: register via the link above and enter promo code cc-switch at recharge to save 10%!\",\n \"packycode\": \"PackyCode is an official partner of CC Switch. Register using this link and enter \\\"cc-switch\\\" promo code during recharge to get 10% off\",\n \"apikeyfun\": \"APIKEY.FUN offers a special deal for CC Switch users. Register through the exclusive link to enjoy up to permanent 5% off top-ups.\",\n \"apinebula\": \"APINEBULA offers CC Switch users a special discount: register using the link and enter the \\\"ccswitch\\\" promo code during your first top-up to get 10% off.\",\n \"atlascloud\": \"Atlas Cloud is a full-modal AI inference platform that gives developers one API for video generation, image generation, and LLM access. Check out its new Coding Plan promotion for more budget-friendly API access.\",\n \"patewayai\": \"PatewayAI offers special benefits for CC Switch users. Register via this link to receive $3 credit.\",\n \"claudeapi\": \"ClaudeAPI offers special benefits for CC Switch users. Register via this link to claim test credits.\",\n \"claudecn\": \"ClaudeCN is an enterprise-grade AI gateway operated by a registered company, supporting enterprise procurement processes with corporate payments, contracts, and compliance guarantees.\",\n \"runapi\": \"RunAPI offers special benefits for CC Switch users. Register via this link and contact customer support to claim ¥14 free credit.\",\n \"minimax_cn\": \"MiniMax Coding Plan Special Offer, Starter from ¥9.9\",\n \"minimax_en\": \"MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)\",\n \"opencode_go\": \"Subscribe to OpenCode Go via this link — only $5 for your first month, plus an extra $5 in credit!\",\n \"dmxapi\": \"Claude Code exclusive model 66% OFF now!\",\n \"cubence\": \"Cubence is an official partner of CC Switch. Register using this link and enter \\\"CCSWITCH\\\" promo code during recharge to get 10% off every top-up\",\n \"aigocode\": \"AIGoCode is an official partner of CC Switch. Register using this link and get 10% bonus credit on your first top-up!\",\n \"rightcode\": \"RightCode is an official partner of CC Switch. Register using this link and get 5% bonus credit on every top-up!\",\n \"aicodemirror\": \"AICodeMirror is an official partner of CC Switch. Register using this link to get 20% off!\",\n \"crazyrouter\": \"CrazyRouter offers an exclusive bonus for CC Switch users — 30% extra credit on your first top-up!\",\n \"sssaicode\": \"SSAI Code offers an exclusive bonus for CC Switch users — $10 extra credit on every top-up!\",\n \"siliconflow\": \"SiliconFlow is an official partner of CC Switch\",\n \"ucloud\": \"Compshare offers an exclusive bonus for CC Switch users — register via this link to get ¥5 platform trial credit!\",\n \"micu\": \"Micu is an official partner of CC Switch\",\n \"etok\": \"Join the ETok community on the official website and subscribe to a plan.\",\n \"shengsuanyun\": \"Shengsuanyun offers exclusive benefits for CC Switch users. New users who register via this link get ¥10 free credits and 10% bonus on first top-up!\",\n \"doubaoseed\": \"DouBao offers exclusive benefits for CC Switch users: register via this link to get 500K tokens of inference credits for all DouBao text models and 5M tokens of free Seedance 2.0 credits.\",\n \"volcengine_agentplan\": \"Volcengine Ark Coding Plan offers exclusive benefits for CC Switch users: subscribe via this link and new customers get 75% off for the first two months, plus an extra 5% off with the exclusive invite code 6J6FV5N2 — as low as ¥9.4/month!\",\n \"byteplus\": \"Register via this link to get 500,000 tokens of free inference quota per model.\",\n \"ccsub\": \"CCSub is a stable, affordable AI API relay — a drop-in replacement for Claude.ai & OpenAI subscriptions, with one key for all models at ~30% of direct API cost.\",\n \"subrouter\": \"SubRouter is an AI relay aggregator that lets one key access multiple models and providers.\",\n \"qiniu\": \"Qiniu Cloud is a multi-protocol AI relay: one key for native Claude, GPT, and Gemini models, with the Anthropic, OpenAI, and Gemini APIs all supported.\",\n \"fenno\": \"Fenno.ai is a stable, efficient API relay giving you access to a wide range of mainstream models. Exclusive CC Switch offer: subscribe via this link to the ¥9.9 Coding Plan worth $150 in credits!\",\n \"zetaapi\": \"ZetaAPI delivers real, undegraded models at as low as 35% of official pricing. Exclusive CC Switch offer: register via this link and use promo code CC-SWITCH for 10% off your first top-up!\",\n \"teamorouter\": \"TeamoRouter is an enterprise-grade Agentic LLM gateway with pricing up to 90% off. It offers exclusive benefits for CC Switch users: register via this link as a new user for 10% off your first top-up!\",\n \"unity2\": \"Unity2.ai offers exclusive benefits for CC Switch users: register via this link to get $2 in credits, plus another $10 for joining the official group — up to $12 in free credits!\"\n },\n \"presets\": {\n \"ucloud\": \"Compshare\",\n \"ucloudCoding\": \"Compshare Coding Plan\",\n \"shengsuanyun\": \"Shengsuanyun\",\n \"qiniu\": \"Qiniu\",\n \"openrouter\": \"OpenRouter\",\n \"deepseek\": \"DeepSeek\",\n \"together\": \"Together AI\"\n },\n \"parameterConfig\": \"Parameter Config - {{name}} *\",\n \"mainModel\": \"Main Model (optional)\",\n \"mainModelPlaceholder\": \"e.g., GLM-4.6\",\n \"fastModel\": \"Fast Model (optional)\",\n \"fastModelPlaceholder\": \"e.g., GLM-4.5-Air\",\n \"modelHint\": \"💡 Leave blank to use provider's default model\",\n \"apiHint\": \"💡 Fill in Claude API compatible service endpoint, avoid trailing slash\",\n \"apiHintOAI\": \"💡 Fill in OpenAI Chat Completions compatible service endpoint, avoid trailing slash\",\n \"apiHintGeminiNative\": \"💡 Prefer a Gemini Native base URL such as https://generativelanguage.googleapis.com or https://generativelanguage.googleapis.com/v1beta; the proxy will append models/*:generateContent automatically\",\n \"codexApiHint\": \"💡 Fill in service endpoint compatible with OpenAI Response format\",\n \"fillSupplierName\": \"Please fill in provider name\",\n \"fillConfigContent\": \"Please fill in configuration content\",\n \"fillParameter\": \"Please fill in {{label}}\",\n \"fillTemplateValue\": \"Please fill in {{label}}\",\n \"endpointRequired\": \"API endpoint is required for non-official providers\",\n \"apiKeyRequired\": \"API Key is required for non-official providers\",\n \"softValidation\": {\n \"title\": \"Configuration has the following issues\",\n \"hint\": \"Save anyway? Switching to this provider may fail; you can fill these in later.\",\n \"saveAnyway\": \"Save anyway\"\n },\n \"configJsonError\": \"Config JSON format error, please check syntax\",\n \"authJsonRequired\": \"auth.json must be a JSON object\",\n \"authJsonError\": \"auth.json format error, please check JSON syntax\",\n \"fillAuthJson\": \"Please fill in auth.json configuration\",\n \"fillApiKey\": \"Please fill in OPENAI_API_KEY\",\n \"visitWebsite\": \"Visit {{url}}\",\n \"anthropicModel\": \"Main Model\",\n \"anthropicSmallFastModel\": \"Fast Model\",\n \"apiFormat\": \"API Format\",\n \"apiFormatHint\": \"Select the input format for the provider's API\",\n \"customUserAgent\": \"Custom User-Agent\",\n \"customUserAgentHint\": \"Only takes effect when local routing/proxy takeover is enabled; replaces the User-Agent in requests forwarded to the provider API.\",\n \"customUserAgentInvalid\": \"User-Agent must not contain control characters (e.g. line breaks); otherwise it will be ignored.\",\n \"customUserAgentPresets\": \"Presets\",\n \"localProxyRequestOverrides\": \"Local proxy request overrides\",\n \"localProxyRequestOverridesHint\": \"Only takes effect after local routing/proxy takeover; applied to the transformed upstream request.\",\n \"localProxyRequestOverridesInvalid\": \"Local proxy request overrides format error: {{error}}\",\n \"localProxyHeaderOverrides\": \"Header overrides\",\n \"localProxyHeaderOverridesInvalidDetail\": \"Header overrides format error: {{error}}\",\n \"localProxyBodyOverrides\": \"Body overrides\",\n \"localProxyBodyOverridesInvalidDetail\": \"Body overrides format error: {{error}}\",\n \"fullUrlLabel\": \"Full URL\",\n \"fullUrlEnabled\": \"Full URL Mode\",\n \"fullUrlDisabled\": \"Mark as Full URL\",\n \"fullUrlHint\": \"💡 Enter the full request URL. This mode requires routing to be enabled, and routing will use the URL as-is without appending a path\",\n \"fullUrlHintGeminiNative\": \"💡 In Gemini Native full URL mode, two inputs are supported: 1. official/structured Gemini URLs, which will still be normalized to the requested model and streaming method; 2. opaque custom relay URLs, which will be used mostly as-is with only query parameters appended\",\n \"apiFormatAnthropic\": \"Anthropic Messages (Native)\",\n \"apiFormatOpenAIChat\": \"OpenAI Chat Completions (Requires routing)\",\n \"apiFormatOpenAIResponses\": \"OpenAI Responses API (Requires routing)\",\n \"apiFormatGeminiNative\": \"Gemini Native generateContent (Requires routing)\",\n \"codexApiFormatResponses\": \"OpenAI Responses API (Native)\",\n \"codexApiFormatOpenAIChat\": \"OpenAI Chat Completions (Requires routing)\",\n \"codexApiFormatHint\": \"Select the Codex API format actually supported by this provider; the config stays on Responses for newer Codex versions, while Chat Completions is converted through local routing.\",\n \"authField\": \"Auth Field\",\n \"authFieldAuthToken\": \"ANTHROPIC_AUTH_TOKEN (Default)\",\n \"authFieldApiKey\": \"ANTHROPIC_API_KEY\",\n \"authFieldHint\": \"Select the authentication env variable name for the config\",\n \"apiHintResponses\": \"💡 Fill in OpenAI Responses API compatible service endpoint, avoid trailing slash\",\n \"anthropicDefaultHaikuModel\": \"Default Haiku Model\",\n \"anthropicDefaultSonnetModel\": \"Default Sonnet Model\",\n \"anthropicDefaultOpusModel\": \"Default Opus Model\",\n \"modelPlaceholder\": \"\",\n \"smallModelPlaceholder\": \"\",\n \"haikuModelPlaceholder\": \"\",\n \"modelHelper\": \"Optional: Specify default Claude model to use, leave blank to use system default.\",\n \"modelMappingLabel\": \"Model Mapping\",\n \"modelMappingHint\": \"Display names only affect the /model menu; 1M is only a Claude Code context-capability declaration.\",\n \"modelRoleLabel\": \"Model role\",\n \"modelRoleSonnet\": \"Sonnet\",\n \"modelRoleOpus\": \"Opus\",\n \"modelRoleFable\": \"Fable\",\n \"modelRoleHaiku\": \"Haiku\",\n \"modelRoleSubagent\": \"Subagent\",\n \"modelDisplayNameLabel\": \"Display name\",\n \"modelDisplayNamePlaceholder\": \"e.g. DeepSeek V4 Pro\",\n \"modelNoDisplayName\": \"Not shown in /model\",\n \"requestModelLabel\": \"Requested model\",\n \"modelOneMHeader\": \"Declare 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"Default fallback model\",\n \"fallbackModelHint\": \"A fallback for requests that don't clearly map to Sonnet, Opus, Fable, or Haiku. Recommended for third-party/relay endpoints—otherwise such requests (including Haiku background subtasks) are forwarded under their original Claude model name and may fail if the upstream doesn't host it. Safe to leave blank for official endpoints.\",\n \"quickSetModels\": \"Quick Set\",\n \"quickSetSuccess\": \"Model name applied to all roles\",\n \"advancedOptionsToggle\": \"Advanced Options\",\n \"advancedOptionsHint\": \"Includes API format, auth field, and model mapping. Defaults work for most use cases.\",\n \"categoryOfficial\": \"Official\",\n \"categoryCnOfficial\": \"Opensource Official\",\n \"categoryAggregation\": \"Aggregation\",\n \"categoryThirdParty\": \"Third Party\",\n \"fetchModels\": \"Fetch Models\",\n \"fetchingModels\": \"Fetching...\",\n \"fetchModelsSuccess\": \"Found {{count}} models\",\n \"fetchModelsFailed\": \"Failed to fetch models\",\n \"fetchModelsEmpty\": \"No models found\",\n \"fetchModelsNeedApiKey\": \"Please fill in API Key first\",\n \"fetchModelsNeedEndpoint\": \"Please fill in API endpoint first\",\n \"fetchModelsNeedConfig\": \"Please fill in API endpoint and API Key first\",\n \"fetchModelsAuthFailed\": \"API Key is invalid or lacks permission\",\n \"fetchModelsNotSupported\": \"This provider does not support fetching model list\",\n \"fetchModelsEndpointNotFound\": \"No reachable models endpoint found. Please check the Base URL or confirm whether the provider exposes this API.\",\n \"fetchModelsTimeout\": \"Request timed out, please check network connection\"\n },\n \"copilot\": {\n \"authSection\": \"GitHub Copilot Authentication\",\n \"authStatus\": \"Authentication Status\",\n \"authenticated\": \"Authenticated as {{username}}\",\n \"notAuthenticated\": \"Not authenticated\",\n \"loginWithGitHub\": \"Login with GitHub\",\n \"loginRequired\": \"Please login to GitHub Copilot first\",\n \"waitingForAuth\": \"Waiting for authorization...\",\n \"enterCode\": \"Please enter the code in your browser:\",\n \"logout\": \"Logout\",\n \"authSuccess\": \"GitHub Copilot authentication successful\",\n \"authFailed\": \"Authentication failed: {{error}}\",\n \"authTimeout\": \"Authentication timed out, please try again\",\n \"tokenExpired\": \"Token expired, please re-authenticate\",\n \"accountCount\": \"{{count}} account(s)\",\n \"selectAccount\": \"Select Account\",\n \"selectAccountPlaceholder\": \"Select a GitHub account\",\n \"useDefaultAccount\": \"Use default account\",\n \"loggedInAccounts\": \"Logged in accounts\",\n \"defaultAccount\": \"Default\",\n \"selected\": \"Selected\",\n \"removeAccount\": \"Remove account\",\n \"setAsDefault\": \"Set as default\",\n \"addAnotherAccount\": \"Add another account\",\n \"logoutAll\": \"Logout all accounts\",\n \"retry\": \"Retry\",\n \"copyCode\": \"Copy code\",\n \"migrationFailed\": \"Legacy auth migration failed: {{error}}\",\n \"loadModelsFailed\": \"Failed to load Copilot models\",\n \"deploymentType\": \"GitHub Deployment Type\",\n \"deploymentGitHubCom\": \"GitHub.com\",\n \"deploymentEnterprise\": \"GitHub Enterprise Server\",\n \"enterpriseDomainPlaceholder\": \"e.g. company.ghe.com\"\n },\n \"codexOauth\": {\n \"authStatus\": \"Auth status\",\n \"notAuthenticated\": \"Not authenticated\",\n \"loginWithChatGPT\": \"Sign in with ChatGPT\",\n \"loginRequired\": \"Please sign in to ChatGPT first\",\n \"waitingForAuth\": \"Waiting for authorization...\",\n \"enterCode\": \"Enter the code in your browser:\",\n \"accountCount\": \"{{count}} account(s)\",\n \"selectAccount\": \"Select account\",\n \"selectAccountPlaceholder\": \"Choose a ChatGPT account\",\n \"useDefaultAccount\": \"Use default account\",\n \"loggedInAccounts\": \"Logged in accounts\",\n \"defaultAccount\": \"Default\",\n \"selected\": \"Selected\",\n \"removeAccount\": \"Remove account\",\n \"setAsDefault\": \"Set as default\",\n \"addAnotherAccount\": \"Add another account\",\n \"logoutAll\": \"Logout all accounts\",\n \"retry\": \"Retry\",\n \"copyCode\": \"Copy code\",\n \"fastMode\": \"FAST mode\",\n \"fastModeDescription\": \"Send service_tier=\\\"priority\\\" for lower latency. Off by default — enabling it consumes your ChatGPT quota at a higher rate.\"\n },\n \"endpointTest\": {\n \"title\": \"API Endpoint Management\",\n \"endpoints\": \"endpoints\",\n \"autoSelect\": \"Auto Select\",\n \"testSpeed\": \"Test\",\n \"testing\": \"Testing\",\n \"addEndpointPlaceholder\": \"https://api.example.com\",\n \"done\": \"Done\",\n \"noEndpoints\": \"No endpoints\",\n \"failed\": \"Failed\",\n \"enterValidUrl\": \"Please enter a valid URL\",\n \"invalidUrlFormat\": \"Invalid URL format\",\n \"onlyHttps\": \"Only HTTP/HTTPS supported\",\n \"urlExists\": \"This URL already exists\",\n \"saveFailed\": \"Save failed, please try again\",\n \"loadEndpointsFailed\": \"Failed to load custom endpoints:\",\n \"addEndpointFailed\": \"Failed to add custom endpoint:\",\n \"removeEndpointFailed\": \"Failed to remove custom endpoint:\",\n \"removeFailed\": \"Remove failed: {{error}}\",\n \"updateLastUsedFailed\": \"Failed to update endpoint last used time\",\n \"pleaseAddEndpoint\": \"Please add an endpoint first\",\n \"testUnavailable\": \"Speed test unavailable\",\n \"noResult\": \"No result returned\",\n \"testFailed\": \"Speed test failed: {{error}}\",\n \"empty\": \"No endpoints\"\n },\n \"providerAdvanced\": {\n \"pricingConfig\": \"Pricing Config\",\n \"useCustomPricing\": \"Use separate config\",\n \"pricingConfigDesc\": \"Configure separate pricing parameters for this provider. Uses global defaults when disabled.\",\n \"costMultiplier\": \"Cost Multiplier\",\n \"costMultiplierPlaceholder\": \"Leave empty to use global default (1)\",\n \"costMultiplierHint\": \"Actual cost = Base cost × Multiplier, supports decimals like 1.5\",\n \"pricingModelSourceLabel\": \"Pricing Mode\",\n \"pricingModelSourceInherit\": \"Inherit global default\",\n \"pricingModelSourceRequest\": \"Request model\",\n \"pricingModelSourceResponse\": \"Response model\",\n \"pricingModelSourceHint\": \"Choose whether to match pricing by request model or response model\"\n },\n \"grokConfig\": {\n \"authJson\": \"Grok API Key (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"xai-your-api-key\\\"\\n}\",\n \"authJsonHint\": \"Shared provider API key; each [model.*] may also define api_key or env_key\",\n \"profileToml\": \"Grok Profile (TOML)\",\n \"profileTomlHint\": \"Manages endpoints, models, subagents and model.* while preserving other global Grok settings\",\n \"defaultModelPlaceholder\": \"e.g. grok-4.5\",\n \"defaultModelHint\": \"The model used by Grok Build by default. Change it anytime, or leave it blank to preserve the current Profile default.\",\n \"advancedSectionHint\": \"Includes the native Grok API backend, User-Agent and local proxy request overrides.\",\n \"editGlobalConfig\": \"Edit Grok Global Config\",\n \"addToGlobalConfig\": \"Add to Global Config\",\n \"addedToGlobalConfig\": \"The current Grok Profile was added to the global config\",\n \"globalConfigTitle\": \"Grok Build global config.toml\",\n \"globalConfigHint\": \"Edits the live local config. Provider switches only replace endpoints, models, subagents and model.*; other settings are preserved and every save is backed up.\",\n \"globalConfigSaved\": \"Grok global config saved\",\n \"applyPrivacy\": \"Apply Privacy Protection\",\n \"privacyApplied\": \"Privacy protection settings applied\"\n },\n \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json configuration content\",\n \"authJsonStorageHint\": \"Proxy takeover is showing the stored auth, not the live auth.json.\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml configuration content\",\n \"configTomlStorageHint\": \"Proxy takeover is showing the stored config, not the live config.toml.\",\n \"proxyTakeoverStorageNotice\": \"💡 This app is under proxy takeover. You are editing the stored provider config; the live config is already temporarily managed by the proxy.\",\n \"writeCommonConfig\": \"Apply Common Config\",\n \"editCommonConfig\": \"Edit Common Config\",\n \"editCommonConfigTitle\": \"Edit Codex Common Config Snippet\",\n \"commonConfigHint\": \"This snippet will be appended to the end of config.toml when 'Apply Common Config' is checked\",\n \"enableGoalMode\": \"Enable Goal mode\",\n \"enableRemoteCompaction\": \"Enable remote compaction\",\n \"remoteCompactionHint\": \"When enabled, the active model_providers entry name is written as OpenAI so Codex can try remote compaction.\",\n \"apiUrlLabel\": \"API Request URL\",\n \"extractFromCurrent\": \"Extract from Editor\",\n \"extractNoCommonConfig\": \"No common config available to extract from editor\",\n \"extractFailed\": \"Extract failed: {{error}}\",\n \"saveFailed\": \"Save failed: {{error}}\",\n \"modelNameHint\": \"Specify the model to use, will be auto-updated in config.toml\",\n \"modelName\": \"Model Name\",\n \"upstreamFormatLabel\": \"Upstream Format\",\n \"upstreamFormatHint\": \"Pick Responses when your provider is natively a Responses API (direct, no format conversion); pick Chat when it uses the Chat Completions protocol; pick Anthropic Messages when it only offers the native Anthropic Messages protocol. Chat and Anthropic Messages both require routing takeover to convert to Responses.\",\n \"upstreamFormatChat\": \"Chat Completions (routing required)\",\n \"upstreamFormatResponses\": \"Responses (native)\",\n \"upstreamFormatAnthropic\": \"Anthropic Messages (routing required)\",\n \"anthropicAuthFieldLabel\": \"Auth field\",\n \"anthropicAuthFieldAuthToken\": \"ANTHROPIC_AUTH_TOKEN (Authorization)\",\n \"anthropicAuthFieldApiKey\": \"ANTHROPIC_API_KEY (x-api-key)\",\n \"anthropicAuthFieldHint\": \"Choose which header carries the API key to the gateway: ANTHROPIC_AUTH_TOKEN sends Authorization: Bearer; ANTHROPIC_API_KEY sends x-api-key. Only one is sent.\",\n \"impersonateClaudeCodeLabel\": \"Emulate Claude Code client\",\n \"impersonateClaudeCodeHint\": \"Enable when the gateway (or its upstream) restricts usage to Claude Code: spoofs the User-Agent, anthropic-beta and x-app headers, and injects the Claude Code identity as the first system prompt line.\",\n \"maxOutputTokensLabel\": \"Max output tokens\",\n \"maxOutputTokensPlaceholder\": \"Leave empty to use the default 8192\",\n \"maxOutputTokensHint\": \"Codex does not forward model_max_output_tokens in the request body, so the default 8192 ceiling can truncate long or thinking-heavy responses (stop_reason=max_tokens). This value overrides the request's max_tokens on the Anthropic path. Do not exceed the real output ceiling of the model/gateway or it may 400. Leave empty to use the default 8192.\",\n \"promptCacheRoutingLabel\": \"Prompt cache routing\",\n \"promptCacheRoutingAuto\": \"Auto (recommended)\",\n \"promptCacheRoutingEnabled\": \"Enabled\",\n \"promptCacheRoutingDisabled\": \"Disabled\",\n \"promptCacheRoutingHint\": \"Auto sends prompt_cache_key only to known-compatible upstreams. Enable it for other compatible gateways, or disable it if a strict gateway rejects unknown fields with HTTP 400. Only a stable client-provided session ID is used.\",\n \"upstreamModelName\": \"Upstream Model Name\",\n \"upstreamModelNameHint\": \"For Chat format, enter the real upstream model here; routing converts Codex Responses requests to Chat Completions while keeping this model.\",\n \"modelNamePlaceholder\": \"e.g., gpt-5-codex\",\n \"contextWindow1M\": \"1M Context Window\",\n \"contextWindow\": \"Context Window\",\n \"contextWindowPlaceholder\": \"e.g., 128000\",\n \"autoCompactLimit\": \"Auto Compact Limit\",\n \"autoCompactLimitPlaceholder\": \"e.g., 90000\",\n \"autoCompactLimitHint\": \"Auto-compacts history when context reaches this token limit\",\n \"modelMetadataAdvanced\": \"Model Metadata Advanced Options\",\n \"modelMetadataAdvancedHint\": \"Overrides Codex metadata for unknown models; clearing a field removes the matching config.toml entry.\",\n \"defaultModelLabel\": \"Default Model\",\n \"defaultModelPlaceholder\": \"e.g. gpt-5.6\",\n \"defaultModelHint\": \"The model Codex requests by default; change it anytime without waiting for a preset update. If left empty while model mapping is configured, the first mapping row is used.\",\n \"defaultModelNotInCatalog\": \"This model is not in the model mapping, so the Codex /model menu won't list it (direct requests still work).\",\n \"addToModelMapping\": \"Add to mapping\",\n \"modelMappingTitle\": \"Model Mapping\",\n \"modelMappingHint\": \"Generates Codex model_catalog_json so /model can show these third-party model names; entries are saved exactly as listed. Codex must be restarted to refresh the model list after changes.\",\n \"addCatalogModel\": \"Add Model\",\n \"catalogDisplayName\": \"Display Name\",\n \"catalogDisplayNamePlaceholder\": \"e.g., DeepSeek V4 Flash\",\n \"catalogModelId\": \"Model ID\",\n \"catalogModelPlaceholder\": \"e.g., deepseek-v4-flash\",\n \"catalogColumnDisplay\": \"Menu Display Name\",\n \"catalogColumnModel\": \"Actual Request Model\",\n \"catalogColumnContext\": \"Context Window\",\n \"reasoningModeToggle\": \"Supports Thinking Mode\",\n \"reasoningModeHint\": \"Enable when the upstream Chat Completions API supports toggling thinking on/off. Providers like Kimi, GLM and Qwen usually fall into this category.\",\n \"reasoningEffortToggle\": \"Supports Reasoning Effort\",\n \"reasoningEffortHint\": \"Enable when the upstream supports thinking-depth control such as low/high/max. Enabling this also turns on thinking mode and converts Codex's reasoning.effort into the upstream Chat parameter.\",\n \"reasoningGroupTitle\": \"Reasoning Capability\",\n \"reasoningSectionHint\": \"Preset providers are configured automatically; custom providers are inferred from name/URL. Override manually only when auto-detection is wrong.\",\n \"advancedSectionHint\": \"Includes upstream format, model mapping, reasoning overrides and custom User-Agent. Providers using the Chat Completions protocol require routing takeover to be enabled.\"\n },\n \"geminiConfig\": {\n \"envFile\": \"Environment Variables (.env)\",\n \"envFileHint\": \"Configure Gemini environment variables in .env format\",\n \"configJson\": \"Configuration File (config.json)\",\n \"configJsonHint\": \"Configure Gemini extended parameters in JSON format (optional)\",\n \"writeCommonConfig\": \"Apply Common Config\",\n \"editCommonConfig\": \"Edit Common Config\",\n \"editCommonConfigTitle\": \"Edit Gemini Common Config Snippet\",\n \"commonConfigHint\": \"This snippet writes to Gemini .env when 'Apply Common Config' is checked (GOOGLE_GEMINI_BASE_URL and GEMINI_API_KEY are not allowed)\",\n \"extractFromCurrent\": \"Extract from Editor\",\n \"extractNoCommonConfig\": \"No common config available to extract from editor\",\n \"extractFailed\": \"Extract failed: {{error}}\",\n \"saveFailed\": \"Save failed: {{error}}\",\n \"extractedConfigInvalid\": \"Extracted config format is invalid\",\n \"invalidJsonFormat\": \"Common config snippet format error (must be valid JSON)\",\n \"commonConfigInvalidKeys\": \"Common config snippet must not include GOOGLE_GEMINI_BASE_URL or GEMINI_API_KEY (found: {{keys}})\",\n \"commonConfigInvalidValues\": \"Common config snippet values must be strings\",\n \"noCommonConfigToApply\": \"Common config snippet is empty or has no applicable entries\",\n \"configMergeFailed\": \"Config merge failed: {{error}}\",\n \"configReplaceFailed\": \"Config replace failed: {{error}}\"\n },\n \"opencode\": {\n \"npmPackage\": \"API Format\",\n \"selectPackage\": \"Select API format\",\n \"npmPackageHint\": \"Select the API format for the AI service\",\n \"baseUrl\": \"Base URL\",\n \"baseUrlHint\": \"Custom API endpoint URL\",\n \"models\": \"Models\",\n \"modelsHint\": \"Configure available models and their display names\",\n \"addModel\": \"Add Model\",\n \"modelId\": \"Model ID\",\n \"modelName\": \"Display Name\",\n \"noModels\": \"No models configured\",\n \"modelsRequired\": \"Please add at least one model\",\n \"toggleModelDetails\": \"Toggle model details\",\n \"providerKey\": \"Provider Key\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.\",\n \"providerKeyLockedHint\": \"This provider has already been added to the app config, so its key can no longer be changed.\",\n \"providerKeyRequired\": \"Provider key is required\",\n \"providerKeyDuplicate\": \"This key is already in use\",\n \"providerKeyInvalid\": \"Invalid format. Use lowercase letters, numbers, and hyphens only.\",\n \"headers\": \"Headers\",\n \"headersHint\": \"Optional HTTP headers sent with provider requests, such as HTTP-Referer or X-Title.\",\n \"addHeader\": \"Add header\",\n \"headerName\": \"Header\",\n \"headerValue\": \"Value\",\n \"headerNamePlaceholder\": \"X-Title\",\n \"headerValuePlaceholder\": \"CC Switch\",\n \"removeHeader\": \"Remove header\",\n \"noHeaders\": \"No custom headers configured\",\n \"extraOptions\": \"Extra Options\",\n \"extraOptionsHint\": \"Configure extra SDK options like timeout, setCacheKey, etc. Values are auto-parsed to appropriate types (number, boolean, etc.).\",\n \"addExtraOption\": \"Add\",\n \"extraOptionKey\": \"Key\",\n \"extraOptionValue\": \"Value\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"No extra options configured\",\n \"noModelOptions\": \"Model options, click + to add\",\n \"modelLimits\": \"Token Limits\",\n \"limitContext\": \"Context\",\n \"limitOutput\": \"Output\",\n \"modelExtraFields\": \"Model Properties\",\n \"noModelExtraFields\": \"Model properties (variants, cost, etc.), click + to add\",\n \"modelExtraFieldKeyPlaceholder\": \"variants\",\n \"sdkOptions\": \"SDK Options\",\n \"modelOptionKeyPlaceholder\": \"provider\",\n \"modelOptionValuePlaceholder\": \"{\\\"order\\\": [\\\"baseten\\\"]}\"\n },\n \"providerPreset\": {\n \"label\": \"Provider Preset\",\n \"custom\": \"Custom Configuration\",\n \"other\": \"Other\",\n \"hint\": \"You can continue to adjust the fields below after selecting a preset.\",\n \"searchTooltip\": \"Search presets\",\n \"searchAriaLabel\": \"Search provider presets\",\n \"searchPlaceholder\": \"Search name, website, or category...\",\n \"sortAriaLabel\": \"Toggle preset sorting\",\n \"sortNameAscTooltip\": \"Sort by name A-Z\",\n \"sortOriginalTooltip\": \"Restore original order\",\n \"noSearchResults\": \"No provider presets match your search.\"\n },\n \"usage\": {\n \"title\": \"Usage Statistics\",\n \"subtitle\": \"View AI model usage and cost statistics\",\n \"today\": \"24 Hours\",\n \"last7days\": \"7 Days\",\n \"last30days\": \"30 Days\",\n \"presetToday\": \"Today\",\n \"preset1d\": \"1d\",\n \"preset7d\": \"7d\",\n \"preset14d\": \"14d\",\n \"preset30d\": \"30d\",\n \"totalRequests\": \"Total Requests\",\n \"totalCost\": \"Total Cost\",\n \"cost\": \"Cost\",\n \"perMillion\": \"(per million)\",\n \"trends\": \"Usage Trends\",\n \"rangeToday\": \"Last 24 hours (hourly)\",\n \"rangeLast7Days\": \"Last 7 days\",\n \"rangeLast30Days\": \"Last 30 days\",\n \"totalTokens\": \"New Tokens (Input+Output)\",\n \"cacheTokens\": \"Cache Tokens\",\n \"realTotal\": \"Tokens Processed\",\n \"realTotalHint\": \"Input + Output + Cache Write + Cache Read\",\n \"freshInput\": \"Fresh Input\",\n \"cacheHitRate\": \"Cache Hit Rate\",\n \"requestLogs\": \"Request Logs\",\n \"providerStats\": \"Provider Stats\",\n \"modelStats\": \"Model Stats\",\n \"time\": \"Time\",\n \"provider\": \"Provider\",\n \"billingModel\": \"Billing Model\",\n \"inputTokens\": \"Input\",\n \"outputTokens\": \"Output\",\n \"cacheReadTokens\": \"Cache Hit\",\n \"cacheCreationTokens\": \"Cache Creation\",\n \"timingInfo\": \"Duration/TTFT\",\n \"status\": \"Status\",\n \"multiplier\": \"Multiplier\",\n \"requestModel\": \"Request Model\",\n \"responseModel\": \"Response Model\",\n \"noData\": \"No data\",\n \"unknownProvider\": \"Unknown Provider\",\n \"stream\": \"Stream\",\n \"nonStream\": \"Non-stream\",\n \"source\": \"Source\",\n \"requestsLabel\": \"requests\",\n \"costLabel\": \"total cost\",\n \"appFilter\": {\n \"all\": \"All\",\n \"claude\": \"Claude Code\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\"\n },\n \"rawInputLabel\": \"Raw\",\n \"dataSources\": \"Data Sources\",\n \"dataSource\": {\n \"proxy\": \"Routing\",\n \"session_log\": \"Session Log\",\n \"codex_db\": \"Codex DB\",\n \"codex_session\": \"Codex Session\",\n \"gemini_session\": \"Gemini Session\",\n \"opencode_session\": \"OpenCode Session\"\n },\n \"sessionSync\": {\n \"trigger\": \"Sync session logs\",\n \"import\": \"Import Sessions\",\n \"resync\": \"Sync\",\n \"imported\": \"Imported {{count}} records from session logs\",\n \"upToDate\": \"Session logs are up to date\",\n \"failed\": \"Session sync failed\"\n },\n \"totalRecords\": \"{{total}} records total\",\n \"goToPage\": \"Go\",\n \"pageInputPlaceholder\": \"Page\",\n \"modelPricing\": \"Model Pricing\",\n \"loadPricingError\": \"Failed to load pricing data\",\n \"modelPricingDesc\": \"Configure token costs for each model\",\n \"noPricingData\": \"No pricing data. Click \\\"Add\\\" to add model pricing configuration.\",\n \"model\": \"Model\",\n \"pricingModel\": \"Pricing Model\",\n \"displayName\": \"Display Name\",\n \"inputCost\": \"Input Cost\",\n \"outputCost\": \"Output Cost\",\n \"cacheReadCost\": \"Cache Hit\",\n \"cacheWriteCost\": \"Cache Creation\",\n \"deleteConfirmTitle\": \"Confirm Delete\",\n \"deleteConfirmDesc\": \"Are you sure you want to delete this model pricing? This action cannot be undone.\",\n \"queryFailed\": \"Query failed\",\n \"refreshUsage\": \"Refresh usage\",\n \"planUsage\": \"Plan usage\",\n \"invalid\": \"Expired\",\n \"total\": \"Total:\",\n \"used\": \"Used:\",\n \"remaining\": \"Remaining:\",\n \"justNow\": \"Just now\",\n \"minutesAgo\": \"{{count}} min ago\",\n \"hoursAgo\": \"{{count}} hr ago\",\n \"daysAgo\": \"{{count}} day ago\",\n \"multiplePlans\": \"{{count}} plans\",\n \"expand\": \"Expand\",\n \"collapse\": \"Collapse\",\n \"modelIdPlaceholder\": \"e.g., claude-3-5-sonnet-20241022\",\n \"displayNamePlaceholder\": \"e.g., Claude 3.5 Sonnet\",\n \"appType\": \"App Type\",\n \"statusCode\": \"Status Code\",\n \"allSources\": \"All Sources\",\n \"allModels\": \"All Models\",\n \"filterBySource\": \"Filter by source\",\n \"filterByModel\": \"Filter by model\",\n \"refreshInterval\": \"Auto-refresh interval\",\n \"refreshOff\": \"Off\",\n \"timeRange\": \"Time Range\",\n \"customRange\": \"Calendar Filter\",\n \"customRangeHint\": \"Supports both date and time\",\n \"startTime\": \"Start Time\",\n \"endTime\": \"End Time\",\n \"liveEndTime\": \"End time follows current time\",\n \"liveEndTimeNow\": \"Now\",\n \"input\": \"Input\",\n \"output\": \"Output\",\n \"cacheWrite\": \"Creation\",\n \"cacheWriteNotReported\": \"OpenAI protocol doesn't distinguish cache creation; only cache hits are reported\",\n \"cacheWritePartial\": \"Some protocols (e.g. OpenAI) don't report cache creation; the value may be incomplete\",\n \"cacheRead\": \"Hit\",\n \"baseCost\": \"Base\",\n \"costMultiplier\": \"Cost Multiplier\",\n \"withMultiplier\": \"with multiplier\",\n \"requestDetail\": \"Request Detail\",\n \"requestNotFound\": \"Request not found\",\n \"basicInfo\": \"Basic Info\",\n \"tokenUsage\": \"Token Usage\",\n \"cacheCreationCost\": \"Cache Creation Cost\",\n \"costBreakdown\": \"Cost Breakdown\",\n \"performance\": \"Performance\",\n \"latency\": \"Latency\",\n \"errorMessage\": \"Error Message\",\n \"requests\": \"Requests\",\n \"tokens\": \"Tokens\",\n \"avgCost\": \"Average Cost\",\n \"avgLatency\": \"Average Latency\",\n \"successRate\": \"Success Rate\",\n \"requestId\": \"Request ID\",\n \"never\": \"Never\",\n \"modelId\": \"Model ID\",\n \"modelIdRequired\": \"Model ID is required\",\n \"inputCostPerMillion\": \"Input Cost (per million tokens, USD)\",\n \"outputCostPerMillion\": \"Output Cost (per million tokens, USD)\",\n \"invalidPrice\": \"Price must be non-negative\",\n \"invalidTimeRange\": \"Please select complete start/end time\",\n \"invalidTimeRangeOrder\": \"Start time cannot be later than end time\",\n \"timeRangeTooLarge\": \"Time range is too large, please narrow it down\",\n \"addPricing\": \"Add Pricing\",\n \"editPricing\": \"Edit Pricing\",\n \"pricingAdded\": \"Pricing added\",\n \"pricingUpdated\": \"Pricing updated\",\n \"importFromModelsDev\": \"Import from models.dev\",\n \"modelsDevHint\": \"Skip manual entry — pick model pricing from models.dev\",\n \"modelsDevPickerTitle\": \"Import Pricing from models.dev\",\n \"modelsDevPickerDesc\": \"Select a model to import (prices in USD per million tokens). One model per import.\",\n \"modelsDevSearchPlaceholder\": \"Search models or providers (full search)...\",\n \"modelsDevAllProviders\": \"All providers\",\n \"modelsDevLoadError\": \"Failed to load models.dev data\",\n \"modelsDevRetry\": \"Retry\",\n \"modelsDevImportButton\": \"Import\",\n \"modelsDevImporting\": \"Importing...\",\n \"modelsDevImported\": \"Imported pricing for {{name}}\",\n \"modelsDevNoResults\": \"No matching models\",\n \"modelsDevTruncated\": \"Showing first {{shown}} of {{total}} results — refine your search\",\n \"modelsDevDefaultHint\": \"Showing the {{shown}} most recently released models (of {{total}}) — type to search all\",\n \"cacheReadCostPerMillion\": \"Cache Read Cost (per million tokens, USD)\",\n \"cacheCreationCostPerMillion\": \"Cache Write Cost (per million tokens, USD)\"\n },\n \"usageScript\": {\n \"title\": \"Configure Usage Query\",\n \"enableUsageQuery\": \"Enable usage query\",\n \"presetTemplate\": \"Preset template\",\n \"requestUrl\": \"Request URL\",\n \"requestUrlPlaceholder\": \"e.g. https://api.example.com\",\n \"method\": \"HTTP method\",\n \"templateCustom\": \"Custom\",\n \"templateGeneral\": \"General\",\n \"templateNewAPI\": \"NewAPI\",\n \"templateCopilot\": \"GitHub Copilot\",\n \"templateTokenPlan\": \"Token Plan\",\n \"templateBalance\": \"Official\",\n \"templateOfficialSubscription\": \"Official Subscription\",\n \"copilotAutoAuth\": \"Auto OAuth authentication, no manual credentials needed\",\n \"tokenPlanHint\": \"Automatically uses the provider's API Key and Base URL to query Token Plan quota\",\n \"balanceHint\": \"Automatically uses the provider's API Key to query account balance\",\n \"officialSubscriptionHint\": \"Reads the local CLI OAuth credentials and calls the official API to query subscription quota. Disabled by default and only requests after you enable it.\",\n \"resetDate\": \"Reset date\",\n \"premiumRequests\": \"Premium Requests\",\n \"credentialsConfig\": \"Credentials\",\n \"credentialsHint\": \"Leave empty to use provider config\",\n \"optional\": \"optional\",\n \"apiKeyPlaceholder\": \"Leave empty to use provider's API Key\",\n \"baseUrlPlaceholder\": \"Leave empty to use provider's base URL\",\n \"baseUrl\": \"Base URL\",\n \"accessToken\": \"Access Token\",\n \"accessTokenPlaceholder\": \"Generate in 'Security Settings'\",\n \"userId\": \"User ID\",\n \"userIdPlaceholder\": \"e.g., 114514\",\n \"accessKeyId\": \"AccessKey ID\",\n \"secretAccessKey\": \"SecretAccessKey\",\n \"volcengineAkSkHint\": \"Volcengine usage query needs an account-level AccessKey ID / Secret (different from the inference API key). Create them in the Volcengine console via the top-right account menu → 'API Access Keys'.\",\n \"volcengineKeyConsoleLink\": \"Create keys at:\",\n \"zhipuTeamHint\": \"Zhipu team plan usage query needs the team API key (reuses this provider's credentials) + organization ID + project ID (sent as bigmodel-organization / bigmodel-project request headers). Find the organization and project IDs in the team admin console's usage page URL.\",\n \"zhipuTeamConsoleLink\": \"Team usage page:\",\n \"organizationId\": \"Organization ID\",\n \"organizationIdPlaceholder\": \"e.g. org-xxxxxx\",\n \"projectId\": \"Project ID\",\n \"projectIdPlaceholder\": \"e.g. proj-xxxxxx\",\n \"defaultPlan\": \"Default Plan\",\n \"queryFailedMessage\": \"Query failed\",\n \"queryScript\": \"Query script (JavaScript)\",\n \"timeoutSeconds\": \"Timeout (seconds)\",\n \"headers\": \"Headers\",\n \"body\": \"Body\",\n \"timeoutHint\": \"Range: 2-30 seconds\",\n \"timeoutMustBeInteger\": \"Timeout must be an integer, decimal part ignored\",\n \"timeoutCannotBeNegative\": \"Timeout cannot be negative\",\n \"autoIntervalMinutes\": \"Auto query interval (minutes, 0 to disable)\",\n \"autoQueryInterval\": \"Auto Query Interval (minutes)\",\n \"autoQueryIntervalHint\": \"0 to disable; recommend 5-60 minutes\",\n \"intervalMustBeInteger\": \"Interval must be an integer, decimal part ignored\",\n \"intervalCannotBeNegative\": \"Interval cannot be negative\",\n \"intervalAdjusted\": \"Interval adjusted to {{value}} minutes\",\n \"scriptHelp\": \"Script writing instructions:\",\n \"configFormat\": \"Configuration format:\",\n \"commentOptional\": \"optional\",\n \"commentResponseIsJson\": \"response is the JSON data returned by the API\",\n \"extractorFormat\": \"Extractor return format (all fields optional):\",\n \"tips\": \"💡 Tips:\",\n \"testing\": \"Testing...\",\n \"testScript\": \"Test script\",\n \"format\": \"Format\",\n \"saveConfig\": \"Save config\",\n \"scriptEmpty\": \"Script configuration cannot be empty\",\n \"mustHaveReturn\": \"Script must contain return statement\",\n \"testSuccess\": \"Test successful!\",\n \"testFailed\": \"Test failed\",\n \"formatSuccess\": \"Format successful\",\n \"formatFailed\": \"Format failed\",\n \"supportedVariables\": \"Supported Variables\",\n \"variablesHint\": \"Supported variables: {{apiKey}}, {{baseUrl}} | extractor function receives API response JSON object\",\n \"scriptConfig\": \"Request configuration\",\n \"extractorCode\": \"Extractor code\",\n \"extractorHint\": \"Return object should include remaining quota fields\",\n \"fieldIsValid\": \"• isValid: Boolean, whether plan is valid\",\n \"fieldInvalidMessage\": \"• invalidMessage: String, reason for expiration (shown when isValid is false)\",\n \"fieldRemaining\": \"• remaining: Number, remaining quota\",\n \"fieldUnit\": \"• unit: String, unit (e.g., \\\"USD\\\")\",\n \"fieldPlanName\": \"• planName: String, plan name\",\n \"fieldTotal\": \"• total: Number, total quota\",\n \"fieldUsed\": \"• used: Number, used quota\",\n \"fieldExtra\": \"• extra: String, custom display text\",\n \"tip1\": \"• Variables {{apiKey}} and {{baseUrl}} are automatically replaced\",\n \"tip2\": \"• Extractor function runs in sandbox environment, supports ES2020+ syntax\",\n \"tip3\": \"• Entire config must be wrapped in () to form object literal expression\"\n },\n \"dbUpgrade\": {\n \"title\": \"Database version is too new\",\n \"description\": \"This database was created by a newer version of CC Switch. Upgrade the app to continue. Upgrading will not delete your data.\",\n \"versionInfo\": \"Database v{{db}} · App supports v{{supported}}\",\n \"dbPath\": \"Database file\",\n \"checking\": \"Checking for available updates…\",\n \"updateAvailable\": \"Version v{{version}} is available; upgrading will let you continue.\",\n \"incompatibleTitle\": \"Upgrading won't fix this\",\n \"incompatibleDescription\": \"You are already on the latest version, but the database (v{{db}}) is still newer than this app supports (v{{supported}}). It was likely created by a third-party client or a newer build, so upgrading the official app cannot make it compatible.\",\n \"preparing\": \"Preparing update…\",\n \"downloading\": \"Downloading update…\",\n \"upgradeNow\": \"Upgrade app\",\n \"retry\": \"Retry upgrade\",\n \"openReleases\": \"Open releases page\",\n \"openConfigDir\": \"Open config folder\",\n \"quit\": \"Quit\"\n },\n \"errors\": {\n \"usage_query_failed\": \"Usage query failed\",\n \"configLoadFailedTitle\": \"Configuration Load Failed\",\n \"configLoadFailedMessage\": \"Unable to read configuration file:\\n{{path}}\\n\\nError details:\\n{{detail}}\\n\\nPlease check if the JSON is valid, or restore from a backup file (e.g., config.json.bak) in the same directory.\\n\\nThe app will exit so you can fix this.\"\n },\n \"presetSelector\": {\n \"title\": \"Select Configuration Type\",\n \"custom\": \"Custom\",\n \"customDescription\": \"Manually configure provider, requires complete configuration\",\n \"officialDescription\": \"Official login, no API Key required\",\n \"presetDescription\": \"Use preset configuration, only API Key required\"\n },\n \"mcp\": {\n \"title\": \"MCP Management\",\n \"import\": \"Import\",\n \"importExisting\": \"Import Existing\",\n \"addMcp\": \"Add MCP\",\n \"claudeTitle\": \"Claude Code MCP Management\",\n \"codexTitle\": \"Codex MCP Management\",\n \"geminiTitle\": \"Gemini MCP Management\",\n \"unifiedPanel\": {\n \"title\": \"MCP Server Management\",\n \"addServer\": \"Add Server\",\n \"editServer\": \"Edit Server\",\n \"deleteServer\": \"Delete Server\",\n \"deleteConfirm\": \"Are you sure you want to delete server \\\"{{id}}\\\"? This action cannot be undone.\",\n \"noServers\": \"No servers yet\",\n \"enabledApps\": \"Enabled Apps\",\n \"noImportFound\": \"No MCP servers to import found. All servers are already managed by CC Switch.\",\n \"importSuccess\": \"Successfully imported {{count}} MCP servers\",\n \"apps\": {\n \"claude\": \"Claude\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\",\n \"openclaw\": \"OpenClaw\",\n \"hermes\": \"Hermes\"\n }\n },\n \"userLevelPath\": \"User-level MCP path\",\n \"serverList\": \"Servers\",\n \"loading\": \"Loading...\",\n \"empty\": \"No MCP servers\",\n \"emptyDescription\": \"Click the button in the top right to add your first MCP server\",\n \"add\": \"Add MCP\",\n \"addServer\": \"Add MCP\",\n \"editServer\": \"Edit MCP\",\n \"addClaudeServer\": \"Add Claude Code MCP\",\n \"editClaudeServer\": \"Edit Claude Code MCP\",\n \"addCodexServer\": \"Add Codex MCP\",\n \"editCodexServer\": \"Edit Codex MCP\",\n \"configPath\": \"Config Path\",\n \"serverCount\": \"{{count}} MCP server(s) configured\",\n \"enabledCount\": \"{{count}} enabled\",\n \"template\": {\n \"fetch\": \"Quick Template: mcp-fetch\"\n },\n \"form\": {\n \"title\": \"MCP Title (Unique)\",\n \"titlePlaceholder\": \"my-mcp-server\",\n \"name\": \"Display Name\",\n \"namePlaceholder\": \"e.g. @modelcontextprotocol/server-time\",\n \"enabledApps\": \"Enable to Apps\",\n \"noAppsWarning\": \"At least one app must be selected\",\n \"description\": \"Description\",\n \"descriptionPlaceholder\": \"Optional description\",\n \"tags\": \"Tags (comma separated)\",\n \"tagsPlaceholder\": \"stdio, time, utility\",\n \"homepage\": \"Homepage\",\n \"homepagePlaceholder\": \"https://example.com\",\n \"docs\": \"Docs\",\n \"docsPlaceholder\": \"https://example.com/docs\",\n \"additionalInfo\": \"Additional Info\",\n \"jsonConfig\": \"Full JSON Configuration\",\n \"jsonConfigOrPrefix\": \"Full JSON configuration or use\",\n \"tomlConfigOrPrefix\": \"Full TOML configuration or use\",\n \"jsonPlaceholder\": \"{\\n \\\"type\\\": \\\"stdio\\\",\\n \\\"command\\\": \\\"uvx\\\",\\n \\\"args\\\": [\\\"mcp-server-fetch\\\"]\\n}\",\n \"tomlConfig\": \"Full TOML Configuration\",\n \"tomlPlaceholder\": \"type = \\\"stdio\\\"\\ncommand = \\\"uvx\\\"\\nargs = [\\\"mcp-server-fetch\\\"]\",\n \"useWizard\": \"Config Wizard\",\n \"syncOtherSide\": \"Mirror to {{target}}\",\n \"syncOtherSideHint\": \"Apply the same settings to {{target}}; existing entries with the same id will be overwritten.\",\n \"willOverwriteWarning\": \"Will overwrite existing config in {{target}}\"\n },\n \"wizard\": {\n \"title\": \"MCP Configuration Wizard\",\n \"hint\": \"Quickly configure MCP server and auto-generate JSON configuration\",\n \"type\": \"Type\",\n \"typeStdio\": \"stdio\",\n \"typeHttp\": \"http\",\n \"typeSse\": \"sse\",\n \"command\": \"Command\",\n \"commandPlaceholder\": \"npx or uvx\",\n \"args\": \"Arguments\",\n \"argsPlaceholder\": \"arg1\\narg2\",\n \"env\": \"Environment Variables\",\n \"envPlaceholder\": \"KEY1=value1\\nKEY2=value2\",\n \"url\": \"URL\",\n \"urlPlaceholder\": \"https://api.example.com/mcp\",\n \"urlRequired\": \"Please enter URL\",\n \"headers\": \"Headers (optional)\",\n \"headersPlaceholder\": \"Authorization: Bearer your_token_here\\nContent-Type: application/json\",\n \"preview\": \"Configuration Preview\",\n \"apply\": \"Apply Configuration\"\n },\n \"id\": \"Identifier (unique)\",\n \"type\": \"Type\",\n \"command\": \"Command\",\n \"validateCommand\": \"Validate Command\",\n \"args\": \"Args\",\n \"argsPlaceholder\": \"e.g., mcp-server-fetch --help\",\n \"env\": \"Environment (one per line, KEY=VALUE)\",\n \"envPlaceholder\": \"FOO=bar\\nHELLO=world\",\n \"reset\": \"Reset\",\n \"msg\": {\n \"saved\": \"Saved\",\n \"deleted\": \"Deleted\",\n \"enabled\": \"Enabled\",\n \"disabled\": \"Disabled\",\n \"templateAdded\": \"Template added\"\n },\n \"error\": {\n \"idRequired\": \"Please enter identifier\",\n \"idExists\": \"Identifier already exists. Please choose another.\",\n \"jsonInvalid\": \"Invalid JSON format\",\n \"tomlInvalid\": \"Invalid TOML format\",\n \"commandRequired\": \"Please enter command\",\n \"singleServerObjectRequired\": \"Please paste a single MCP server object (do not include top-level mcpServers)\",\n \"saveFailed\": \"Save failed\",\n \"deleteFailed\": \"Delete failed\"\n },\n \"validation\": {\n \"ok\": \"Command available\",\n \"fail\": \"Command not found\"\n },\n \"confirm\": {\n \"deleteTitle\": \"Delete MCP Server\",\n \"deleteMessage\": \"Are you sure you want to delete MCP server \\\"{{id}}\\\"? This action cannot be undone.\"\n },\n \"presets\": {\n \"title\": \"Select MCP Type\",\n \"enable\": \"Enable\",\n \"enabled\": \"Enabled\",\n \"installed\": \"Installed\",\n \"docs\": \"Docs\",\n \"requiresEnv\": \"Requires env\",\n \"fetch\": {\n \"name\": \"mcp-server-fetch\",\n \"description\": \"Universal HTTP request tool, supports GET/POST and other HTTP methods, suitable for quick API requests and web data scraping\"\n },\n \"time\": {\n \"name\": \"@modelcontextprotocol/server-time\",\n \"description\": \"Time query tool providing current time, timezone conversion, and date calculation features\"\n },\n \"memory\": {\n \"name\": \"@modelcontextprotocol/server-memory\",\n \"description\": \"Knowledge graph memory system supporting entities, relations, and observations to help AI remember important information from conversations\"\n },\n \"sequential-thinking\": {\n \"name\": \"@modelcontextprotocol/server-sequential-thinking\",\n \"description\": \"Sequential thinking tool helping AI break down complex problems into multiple steps for deeper thinking\"\n },\n \"context7\": {\n \"name\": \"@upstash/context7-mcp\",\n \"description\": \"Context7 documentation search tool providing latest library docs and code examples, with higher limits when configured with a key\"\n }\n }\n },\n \"prompts\": {\n \"manage\": \"Prompts\",\n \"title\": \"{{appName}} Prompt Management\",\n \"claudeTitle\": \"Claude Prompt Management\",\n \"codexTitle\": \"Codex Prompt Management\",\n \"add\": \"Add Prompt\",\n \"edit\": \"Edit Prompt\",\n \"addTitle\": \"Add {{appName}} Prompt\",\n \"editTitle\": \"Edit {{appName}} Prompt\",\n \"import\": \"Import Existing\",\n \"count\": \"{{count}} prompts\",\n \"enabled\": \"Enabled\",\n \"enable\": \"Enable\",\n \"enabledName\": \"Enabled: {{name}}\",\n \"noneEnabled\": \"No prompt enabled\",\n \"currentFile\": \"Current {{filename}} Content\",\n \"empty\": \"No prompts yet\",\n \"emptyDescription\": \"Click the button above to add or import prompts\",\n \"loading\": \"Loading...\",\n \"name\": \"Name\",\n \"namePlaceholder\": \"e.g., Default Project Prompt\",\n \"description\": \"Description\",\n \"descriptionPlaceholder\": \"Optional description\",\n \"content\": \"Content\",\n \"contentPlaceholder\": \"# {{filename}}\\n\\nEnter prompt content here...\",\n \"loadFailed\": \"Failed to load prompts\",\n \"saveSuccess\": \"Saved successfully\",\n \"saveFailed\": \"Failed to save\",\n \"deleteSuccess\": \"Deleted successfully\",\n \"deleteFailed\": \"Failed to delete\",\n \"enableSuccess\": \"Enabled successfully\",\n \"enableFailed\": \"Failed to enable\",\n \"disableSuccess\": \"Disabled successfully\",\n \"disableFailed\": \"Failed to disable\",\n \"importSuccess\": \"Imported successfully\",\n \"importFailed\": \"Failed to import\",\n \"confirm\": {\n \"deleteTitle\": \"Confirm Delete\",\n \"deleteMessage\": \"Are you sure you want to delete prompt \\\"{{name}}\\\"?\"\n }\n },\n \"profiles\": {\n \"switcherTooltip\": {\n \"claude\": \"Projects: switch the Claude Code provider / MCP / Skills / memory files in one click\",\n \"claude-desktop\": \"Projects: switch the Claude Desktop provider in one click\",\n \"codex\": \"Projects: switch the Codex provider / MCP / Skills / memory files in one click\"\n },\n \"none\": \"No project\",\n \"searchPlaceholder\": \"Search projects\",\n \"empty\": \"No projects yet\",\n \"createFromCurrent\": \"New project\",\n \"createDescription\": {\n \"claude\": \"Save the current Claude Code provider, MCP, Skills and memory file as a project you can switch to later.\",\n \"claude-desktop\": \"Save the current Claude Desktop provider as a project you can switch to later.\",\n \"codex\": \"Save the current Codex provider, MCP, Skills and memory file configuration as a project you can switch to later.\"\n },\n \"manage\": \"Manage projects…\",\n \"manageTitle\": \"Manage Projects\",\n \"manageDescription\": \"Projects are configuration snapshots. When you switch projects, the current configuration is automatically saved back to the project you are leaving.\",\n \"namePlaceholder\": \"e.g. Development, Drawing\",\n \"rename\": \"Rename\",\n \"noSnapshotForScope\": \"Not saved for this app yet\",\n \"delete\": \"Delete\",\n \"deleteConfirmTitle\": \"Confirm Delete\",\n \"deleteConfirmMessage\": \"Are you sure you want to delete project \\\"{{name}}\\\"? Your current configuration will not be changed.\",\n \"createSuccess\": \"Project created\",\n \"createFailed\": \"Failed to create project: {{detail}}\",\n \"updateSuccess\": \"Project updated\",\n \"updateFailed\": \"Failed to update project: {{detail}}\",\n \"deleteSuccess\": \"Project deleted\",\n \"deleteFailed\": \"Failed to delete project: {{detail}}\",\n \"applySuccess\": \"Project applied\",\n \"applyFailed\": \"Failed to apply project: {{detail}}\",\n \"applyWarnings\": \"Project applied with {{warningCount}} warning(s):\\n{{details}}\",\n \"clearSuccess\": \"Switched to no project\"\n },\n \"workspace\": {\n \"title\": \"Workspace Files\",\n \"manage\": \"Workspace\",\n \"files\": {\n \"agents\": \"Agent instructions and rules\",\n \"soul\": \"Agent personality and communication style\",\n \"user\": \"User profile and preferences\",\n \"identity\": \"Agent name and avatar\",\n \"tools\": \"Local tool documentation\",\n \"memory\": \"Long-term memory and decisions\",\n \"heartbeat\": \"Heartbeat run checklist\",\n \"bootstrap\": \"First-run bootstrap ritual\",\n \"boot\": \"Gateway reboot checklist\"\n },\n \"editing\": \"Edit {{filename}}\",\n \"saveSuccess\": \"Saved successfully\",\n \"saveFailed\": \"Failed to save\",\n \"loadFailed\": \"Failed to load\",\n \"openDirectory\": \"Open in file manager\",\n \"dailyMemory\": {\n \"title\": \"Daily Memory\",\n \"sectionTitle\": \"Daily Memory\",\n \"cardTitle\": \"Daily Memory Files\",\n \"cardDescription\": \"Browse & manage daily memories\",\n \"createToday\": \"Add Memory\",\n \"empty\": \"No daily memory files yet\",\n \"loadFailed\": \"Failed to load daily memory files\",\n \"createFailed\": \"Failed to create daily memory file\",\n \"deleteSuccess\": \"Daily memory file deleted\",\n \"deleteFailed\": \"Failed to delete daily memory file\",\n \"confirmDeleteTitle\": \"Delete Daily Memory\",\n \"confirmDeleteMessage\": \"Delete the daily memory for {{date}}? This cannot be undone.\",\n \"searchPlaceholder\": \"Search full content...\",\n \"searchScopeHint\": \"Full-text search across all daily memories. ⌘F\",\n \"searchCloseHint\": \"Esc to close\",\n \"noSearchResults\": \"No daily memories match your search.\",\n \"searching\": \"Searching...\",\n \"searchFailed\": \"Search failed\",\n \"matchCount\": \"{{count}} match(es)\"\n }\n },\n \"openclaw\": {\n \"backupCreated\": \"Backup created: {{path}}\",\n \"providerKey\": \"Provider Key\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"Unique identifier in config file. Use lowercase letters, numbers, and hyphens only.\",\n \"providerKeyLockedHint\": \"This provider has already been added to the app config, so its key can no longer be changed.\",\n \"providerKeyRequired\": \"Provider key is required\",\n \"providerKeyDuplicate\": \"This key is already in use\",\n \"providerKeyInvalid\": \"Invalid format. Use lowercase letters, numbers, and hyphens only.\",\n \"apiProtocol\": \"API Protocol\",\n \"selectProtocol\": \"Select API Protocol\",\n \"apiProtocolHint\": \"Select the protocol type compatible with the provider's API. Most providers use OpenAI Completions format.\",\n \"baseUrl\": \"API Endpoint\",\n \"baseUrlHint\": \"The provider's API endpoint address.\",\n \"models\": \"Models\",\n \"addModel\": \"Add Model\",\n \"noModels\": \"No models configured. Click Add Model to configure available models.\",\n \"modelId\": \"Model ID\",\n \"modelIdPlaceholder\": \"claude-3-sonnet\",\n \"modelName\": \"Display Name\",\n \"modelNamePlaceholder\": \"Claude 3 Sonnet\",\n \"contextWindow\": \"Context Window\",\n \"maxTokens\": \"Max Output Tokens\",\n \"reasoning\": \"Reasoning Mode\",\n \"reasoningOn\": \"Enabled\",\n \"reasoningOff\": \"Disabled\",\n \"inputTypes\": \"Input Types\",\n \"inputCost\": \"Input Cost ($/M tokens)\",\n \"outputCost\": \"Output Cost ($/M tokens)\",\n \"advancedOptions\": \"Advanced Options\",\n \"cacheReadCost\": \"Cache Read Cost ($/M tokens)\",\n \"cacheWriteCost\": \"Cache Write Cost ($/M tokens)\",\n \"cacheCostHint\": \"Cache costs are used to calculate Prompt Caching costs. Leave empty if not using caching.\",\n \"modelsHint\": \"Configure the models supported by this provider. Model ID is used for API calls, Display Name for the interface.\",\n \"userAgent\": \"Send User-Agent\",\n \"userAgentHint\": \"Some providers require a browser User-Agent header to work properly.\",\n \"env\": {\n \"title\": \"Environment Variables\",\n \"description\": \"Manage environment variables in openclaw.json (API keys, custom variables, etc.)\",\n \"editorHint\": \"Edit the full env section as JSON. Nested objects such as env.vars and env.shellEnv are supported.\",\n \"objectRequired\": \"OpenClaw env must be a JSON object.\",\n \"invalidJson\": \"OpenClaw env must be valid JSON.\",\n \"empty\": \"OpenClaw env cannot be empty. Use {} for an empty object.\",\n \"keyPlaceholder\": \"Variable name\",\n \"valuePlaceholder\": \"Value\",\n \"add\": \"Add Variable\",\n \"saveSuccess\": \"Environment variables saved\",\n \"saveFailed\": \"Failed to save environment variables\",\n \"loadFailed\": \"Failed to load environment variables\",\n \"duplicateKey\": \"Duplicate variable name detected: {{key}}\"\n },\n \"tools\": {\n \"title\": \"Tool Permissions\",\n \"description\": \"Manage tool permissions in openclaw.json (allow/deny lists)\",\n \"profile\": \"Permission Profile\",\n \"profileMinimal\": \"Minimal\",\n \"profileCoding\": \"Coding\",\n \"profileMessaging\": \"Messaging\",\n \"profileFull\": \"Full\",\n \"profileUnset\": \"Not set\",\n \"unsupportedProfileTitle\": \"Unsupported tools profile detected\",\n \"unsupportedProfileDescription\": \"The current tools.profile value '{{value}}' is not in the supported OpenClaw list. It will be preserved until you choose a new value.\",\n \"unsupportedProfileLabel\": \"unsupported\",\n \"allowList\": \"Allow List\",\n \"denyList\": \"Deny List\",\n \"patternPlaceholder\": \"Tool name or pattern\",\n \"addAllow\": \"Add Allow\",\n \"addDeny\": \"Add Deny\",\n \"saveSuccess\": \"Tool permissions saved\",\n \"saveFailed\": \"Failed to save tool permissions\",\n \"loadFailed\": \"Failed to load tool permissions\"\n },\n \"agents\": {\n \"title\": \"Agents Config\",\n \"description\": \"Manage agents.defaults in openclaw.json (default model, runtime parameters, etc.)\",\n \"modelSection\": \"Model Configuration\",\n \"primaryModel\": \"Default Model\",\n \"primaryModelHint\": \"Select from models of configured providers\",\n \"notSet\": \"Not set\",\n \"fallbackModels\": \"Fallback Models\",\n \"fallbackModelsHint\": \"When the primary model is unavailable, fallbacks are tried in order\",\n \"addFallback\": \"Add fallback model\",\n \"noModels\": \"No configured provider models. Please add an OpenClaw provider first.\",\n \"notInList\": \"{{value}} (not configured)\",\n \"runtimeSection\": \"Runtime Parameters\",\n \"workspace\": \"Workspace Path\",\n \"timeout\": \"Timeout (seconds)\",\n \"contextTokens\": \"Context Tokens\",\n \"maxConcurrent\": \"Max Concurrent\",\n \"legacyTimeoutTitle\": \"Legacy timeout detected\",\n \"legacyTimeoutDescription\": \"This config still uses agents.defaults.timeout. Saving here will migrate it to timeoutSeconds.\",\n \"saveSuccess\": \"Agents config saved\",\n \"saveFailed\": \"Failed to save agents config\",\n \"loadFailed\": \"Failed to load agents config\"\n },\n \"health\": {\n \"title\": \"OpenClaw config warnings detected\",\n \"invalidToolsProfile\": \"tools.profile contains an unsupported value. OpenClaw currently expects minimal, coding, messaging, or full.\",\n \"legacyTimeout\": \"agents.defaults.timeout is deprecated. Save the Agents panel to migrate it to timeoutSeconds.\",\n \"stringifiedEnvVars\": \"env.vars should be an object, but the current value looks stringified or malformed.\",\n \"stringifiedShellEnv\": \"env.shellEnv should be an object, but the current value looks stringified or malformed.\",\n \"parseFailed\": \"openclaw.json could not be parsed as valid JSON5. Fix the file before editing it here.\"\n },\n \"primaryModel\": \"Primary Model\",\n \"fallbackModel\": \"Fallback Model\"\n },\n \"hermes\": {\n \"form\": {\n \"baseUrl\": \"API Endpoint\",\n \"baseUrlHint\": \"The API endpoint URL for this provider.\",\n \"providerKey\": \"Provider Key\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"Lowercase letters, numbers, and hyphens only. Used as the provider name in config.yaml.\",\n \"providerKeyLockedHint\": \"This provider is in Hermes config; key is locked.\",\n \"providerKeyRequired\": \"Provider key is required\",\n \"providerKeyInvalid\": \"Provider key can only contain lowercase letters, numbers, and hyphens\",\n \"providerKeyDuplicate\": \"This provider key already exists\",\n \"apiMode\": \"API Mode\",\n \"apiModeHint\": \"Provider API protocol. Choose the format that matches your endpoint.\",\n \"apiModeChatCompletions\": \"OpenAI Chat Completions\",\n \"apiModeAnthropicMessages\": \"Anthropic Messages\",\n \"apiModeCodexResponses\": \"OpenAI Responses\",\n \"apiModeBedrockConverse\": \"AWS Bedrock Converse\",\n \"baseUrlRequired\": \"API endpoint is required\",\n \"baseUrlScheme\": \"Use an http:// or https:// address\",\n \"baseUrlInvalid\": \"API endpoint is not a valid URL\",\n \"models\": \"Models\",\n \"addModel\": \"Add model\",\n \"noModels\": \"No models configured. Switching to this provider won't change the default model.\",\n \"modelId\": \"Model ID\",\n \"modelIdPlaceholder\": \"anthropic/claude-opus-4-8\",\n \"modelName\": \"Display name\",\n \"modelNamePlaceholder\": \"Claude Opus 4.8\",\n \"contextLength\": \"Context length\",\n \"advancedOptions\": \"Advanced options\",\n \"modelsHint\": \"On switch, the first model is written to top-level model.default.\",\n \"primaryModel\": \"Default\",\n \"fallbackModel\": \"Alternate\",\n \"providerAdvanced\": \"Provider advanced options\",\n \"rateLimitDelay\": \"Rate limit delay (seconds)\",\n \"rateLimitDelayHint\": \"Minimum delay in seconds between consecutive requests (optional). Leave empty for no limit.\"\n },\n \"webui\": {\n \"open\": \"Open Hermes Web UI\",\n \"offline\": \"Hermes Web UI is not running. Start it with `hermes dashboard` first.\",\n \"openFailed\": \"Failed to open Hermes Web UI\",\n \"launchConfirmTitle\": \"Hermes Dashboard is not running\",\n \"launchConfirmMessage\": \"Open a terminal and start it now with `hermes dashboard`?\\n\\nThe browser will open automatically once startup completes.\\n\\nIf the terminal reports that `hermes` cannot be found or the web extras are missing, run first:\\npip install hermes-agent[web]\",\n \"launchConfirmAction\": \"Open terminal & launch\",\n \"launching\": \"Started `hermes dashboard` in a terminal.\",\n \"launchFailed\": \"Failed to open terminal\"\n },\n \"memory\": {\n \"title\": \"Memory\",\n \"agentTab\": \"Agent Memory (MEMORY.md)\",\n \"userTab\": \"User Profile (USER.md)\",\n \"usage\": \"{{current}} / {{limit}} characters\",\n \"overLimit\": \"Over budget — Hermes will truncate on next load\",\n \"enableOn\": \"Enabled\",\n \"enableOff\": \"Disabled\",\n \"disabledHint\": \"Hermes will skip this memory until re-enabled\",\n \"toggleFailed\": \"Failed to toggle memory\",\n \"saveSuccess\": \"Memory saved\",\n \"saveFailed\": \"Failed to save memory\",\n \"loadFailed\": \"Failed to read memory file\",\n \"openConfig\": \"Adjust limits in Hermes Web UI\",\n \"runtimeNote\": \"Changes apply on Hermes restart or new session.\"\n }\n },\n \"env\": {\n \"warning\": {\n \"title\": \"Environment Variable Conflicts Detected\",\n \"description\": \"Found {{count}} environment variables that may override your configuration\"\n },\n \"actions\": {\n \"expand\": \"View Details\",\n \"collapse\": \"Collapse\",\n \"selectAll\": \"Select All\",\n \"clearSelection\": \"Clear Selection\",\n \"deleteSelected\": \"Delete Selected ({{count}})\",\n \"deleting\": \"Deleting...\"\n },\n \"field\": {\n \"value\": \"Value\",\n \"source\": \"Source\"\n },\n \"source\": {\n \"userRegistry\": \"User Environment Variable (Registry)\",\n \"systemRegistry\": \"System Environment Variable (Registry)\",\n \"systemEnv\": \"System Environment Variable\"\n },\n \"delete\": {\n \"success\": \"Environment variables deleted successfully\",\n \"error\": \"Failed to delete environment variables\"\n },\n \"backup\": {\n \"location\": \"Backup location: {{path}}\"\n },\n \"confirm\": {\n \"title\": \"Confirm Delete Environment Variables\",\n \"message\": \"Are you sure you want to delete {{count}} environment variable(s)?\",\n \"backupNotice\": \"A backup will be created automatically before deletion. You can restore it later. Changes take effect after restarting the application or terminal.\",\n \"confirm\": \"Confirm Delete\"\n },\n \"error\": {\n \"noSelection\": \"Please select environment variables to delete\"\n }\n },\n \"skills\": {\n \"manage\": \"Skills\",\n \"title\": \"Skills Management\",\n \"description\": \"Discover and install skills from popular repositories to extend Claude Code/Codex/Gemini capabilities\",\n \"refresh\": \"Refresh\",\n \"refreshing\": \"Refreshing...\",\n \"repoManager\": \"Repository Management\",\n \"count\": \"{{count}} skills\",\n \"empty\": \"No skills available\",\n \"emptyDescription\": \"Add skill repositories to discover available skills\",\n \"addRepo\": \"Add Skill Repository\",\n \"loading\": \"Loading...\",\n \"installed\": \"Installed\",\n \"install\": \"Install\",\n \"installing\": \"Installing...\",\n \"uninstall\": \"Uninstall\",\n \"uninstalling\": \"Uninstalling...\",\n \"view\": \"View\",\n \"noDescription\": \"No description\",\n \"loadFailed\": \"Failed to load\",\n \"installSuccess\": \"Skill {{name}} installed\",\n \"installFailed\": \"Failed to install\",\n \"uninstallSuccess\": \"Skill {{name}} uninstalled\",\n \"uninstallFailed\": \"Failed to uninstall\",\n \"update\": \"Update\",\n \"updating\": \"Updating...\",\n \"updateAvailable\": \"Update\",\n \"updateSuccess\": \"Skill {{name}} updated to latest version\",\n \"updateFailed\": \"Failed to update\",\n \"checkUpdates\": \"Check Updates\",\n \"checkingUpdates\": \"Checking...\",\n \"noUpdates\": \"All skills are up to date\",\n \"updatesFound\": \"{{count}} skill(s) have updates available\",\n \"updateAll\": \"Update All ({{count}})\",\n \"updatingAll\": \"Updating...\",\n \"updateAllSuccess\": \"Successfully updated {{count}} skill(s)\",\n \"error\": {\n \"skillNotFound\": \"Skill not found: {{directory}}\",\n \"missingRepoInfo\": \"Missing repository info (owner or name)\",\n \"downloadTimeout\": \"Download repository {{owner}}/{{name}} timeout ({{timeout}}s)\",\n \"downloadTimeoutHint\": \"Please check network connection or retry later\",\n \"skillPathNotFound\": \"Skill path '{{path}}' not found in repository {{owner}}/{{name}}\",\n \"skillDirNotFound\": \"Skill directory not found: {{path}}\",\n \"directoryConflict\": \"Skill directory '{{directory}}' is already occupied by {{existing_repo}}, cannot install from {{new_repo}}\",\n \"emptyArchive\": \"Downloaded archive is empty\",\n \"downloadFailed\": \"Download failed: HTTP {{status}}\",\n \"allBranchesFailed\": \"All branches failed, tried: {{branches}}\",\n \"httpError\": \"HTTP error {{status}}\",\n \"http403\": \"GitHub access restricted, possibly rate limited\",\n \"http404\": \"Repository or branch not found, please check URL\",\n \"http429\": \"Too many requests, please wait and retry\",\n \"parseMetadataFailed\": \"Failed to parse skill metadata\",\n \"getHomeDirFailed\": \"Unable to get user home directory\",\n \"noSkillsInZip\": \"No skills found in ZIP file (requires SKILL.md file)\",\n \"networkError\": \"Network error\",\n \"fsError\": \"File system error\",\n \"unknownError\": \"Unknown error\",\n \"suggestion\": {\n \"checkNetwork\": \"Please check network connection\",\n \"checkProxy\": \"Consider configuring HTTP proxy\",\n \"retryLater\": \"Please retry later\",\n \"checkRepoUrl\": \"Please check repository URL and branch name\",\n \"checkDiskSpace\": \"Please check disk space\",\n \"checkPermission\": \"Please check directory permissions\",\n \"uninstallFirst\": \"Please uninstall the existing skill with the same name first\",\n \"checkZipContent\": \"Please verify the ZIP file contains valid skill directories (with SKILL.md files)\"\n }\n },\n \"repo\": {\n \"title\": \"Manage Skill Repositories\",\n \"description\": \"Add or remove GitHub skill repository sources\",\n \"url\": \"Repository URL\",\n \"urlPlaceholder\": \"owner/name or https://github.com/owner/name\",\n \"branch\": \"Branch\",\n \"branchPlaceholder\": \"main\",\n \"path\": \"Skills Path\",\n \"pathPlaceholder\": \"skills (optional, leave empty for root)\",\n \"add\": \"Add Repository\",\n \"list\": \"Added Repositories\",\n \"empty\": \"No repositories\",\n \"invalidUrl\": \"Invalid repository URL format\",\n \"addSuccess\": \"Repository {{owner}}/{{name}} added, detected {{count}} skills\",\n \"addFailed\": \"Failed to add\",\n \"removeSuccess\": \"Repository {{owner}}/{{name}} removed\",\n \"removeFailed\": \"Failed to remove\",\n \"skillCount\": \"{{count}} skills detected\"\n },\n \"search\": \"Search Skills\",\n \"searchPlaceholder\": \"Search skill name or repo...\",\n \"searchSource\": {\n \"repos\": \"Repos\",\n \"skillssh\": \"skills.sh\"\n },\n \"skillssh\": {\n \"searchPlaceholder\": \"Search skills.sh (min 2 chars)...\",\n \"installs\": \"{{count}} installs\",\n \"loadMore\": \"Load More\",\n \"loading\": \"Searching skills.sh...\",\n \"noResults\": \"No skills found for \\\"{{query}}\\\"\",\n \"error\": \"Failed to search skills.sh\",\n \"poweredBy\": \"Powered by skills.sh\"\n },\n \"filter\": {\n \"placeholder\": \"Filter by status\",\n \"all\": \"All\",\n \"installed\": \"Installed\",\n \"uninstalled\": \"Not installed\",\n \"repo\": \"Filter by repo\",\n \"allRepos\": \"All repos\"\n },\n \"noResults\": \"No matching skills found\",\n \"noInstalled\": \"No skills installed\",\n \"noInstalledDescription\": \"Discover and install skills from repositories, or import existing skills\",\n \"discover\": \"Discover Skills\",\n \"import\": \"Import Existing\",\n \"importDescription\": \"Select skills to import into CC Switch unified management\",\n \"importSuccess\": \"Successfully imported {{count}} skills\",\n \"importSelected\": \"Import Selected ({{count}})\",\n \"noUnmanagedFound\": \"No skills to import found. All skills are already managed by CC Switch.\",\n \"unmanagedAvailable\": \"Skills available to import\",\n \"foundIn\": \"Found in\",\n \"local\": \"Local\",\n \"uninstallConfirm\": \"Are you sure you want to uninstall \\\"{{name}}\\\"? This will remove the skill from all apps and create a local backup first.\",\n \"uninstallInMainPanel\": \"Please uninstall skills from the main panel\",\n \"notFound\": \"Skill not found\",\n \"backup\": {\n \"location\": \"Backup location: {{path}}\"\n },\n \"restoreFromBackup\": {\n \"button\": \"Restore Backup\",\n \"title\": \"Restore From Backup\",\n \"description\": \"Choose a Skills backup to restore its files locally and add it back to the current list.\",\n \"empty\": \"No Skills backups available to restore\",\n \"createdAt\": \"Backed up at\",\n \"path\": \"Backup path\",\n \"restore\": \"Restore\",\n \"restoring\": \"Restoring...\",\n \"delete\": \"Delete\",\n \"deleting\": \"Deleting...\",\n \"deleteSuccess\": \"Deleted backup for {{name}}\",\n \"deleteFailed\": \"Failed to delete skill backup\",\n \"deleteConfirmTitle\": \"Delete Backup\",\n \"deleteConfirmMessage\": \"Are you sure you want to delete the backup for \\\"{{name}}\\\"? This action cannot be undone.\",\n \"success\": \"Skill {{name}} restored from backup\",\n \"failed\": \"Failed to restore from backup\"\n },\n \"apps\": {\n \"claude\": \"Claude\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\",\n \"openclaw\": \"OpenClaw\"\n },\n \"installFromZip\": {\n \"button\": \"Install from ZIP\",\n \"installing\": \"Installing...\",\n \"successSingle\": \"Skill {{name}} installed\",\n \"successMultiple\": \"Successfully installed {{count}} skills\",\n \"noSkillsFound\": \"No skills found in ZIP file (requires SKILL.md file)\"\n }\n },\n \"deeplink\": {\n \"confirmImport\": \"Confirm Import Provider\",\n \"confirmImportDescription\": \"The following configuration will be imported from deep link into CC Switch\",\n \"importPrompt\": \"Import Prompt\",\n \"importPromptDescription\": \"Please confirm whether to import this system prompt\",\n \"importMcp\": \"Import MCP Servers\",\n \"importMcpDescription\": \"Please confirm whether to import these MCP Servers\",\n \"importSkill\": \"Add Skill Repository\",\n \"importSkillDescription\": \"Please confirm whether to add this Skill repository\",\n \"promptImportSuccess\": \"Prompt imported successfully\",\n \"promptImportSuccessDescription\": \"Imported prompt: {{name}}\",\n \"mcpImportSuccess\": \"MCP Servers imported successfully\",\n \"mcpImportSuccessDescription\": \"Successfully imported {{count}} server(s)\",\n \"mcpPartialSuccess\": \"Partial import success\",\n \"mcpPartialSuccessDescription\": \"Success: {{success}}, Failed: {{failed}}\",\n \"skillImportSuccess\": \"Skill repository added successfully\",\n \"skillImportSuccessDescription\": \"Added repository: {{repo}}\",\n \"app\": \"App Type\",\n \"providerName\": \"Provider Name\",\n \"homepage\": \"Homepage\",\n \"endpoint\": \"API Endpoint\",\n \"apiKey\": \"API Key\",\n \"icon\": \"Icon\",\n \"model\": \"Model\",\n \"haikuModel\": \"Haiku Model\",\n \"sonnetModel\": \"Sonnet Model\",\n \"opusModel\": \"Opus Model\",\n \"multiModel\": \"Multi-Modal Model\",\n \"notes\": \"Notes\",\n \"import\": \"Import\",\n \"importing\": \"Importing...\",\n \"warning\": \"Please confirm the information above is correct before importing. You can edit or delete it later in the provider list.\",\n \"parseError\": \"Failed to parse deep link\",\n \"importSuccess\": \"Import successful\",\n \"importSuccessDescription\": \"Provider \\\"{{name}}\\\" has been successfully imported\",\n \"importError\": \"Failed to import\",\n \"configSource\": \"Config Source\",\n \"configEmbedded\": \"Embedded Config\",\n \"configRemote\": \"Remote Config\",\n \"configDetails\": \"Config Details\",\n \"configUrl\": \"Config File URL\",\n \"configMergeError\": \"Failed to merge configuration file\",\n \"primaryEndpoint\": \"Primary\",\n \"mcp\": {\n \"title\": \"Batch Import MCP Servers\",\n \"targetApps\": \"Target Apps\",\n \"serverCount\": \"MCP Servers ({{count}})\",\n \"enabledWarning\": \"After import, configurations will be written to all specified apps immediately\"\n },\n \"prompt\": {\n \"title\": \"Import System Prompt\",\n \"app\": \"App\",\n \"name\": \"Name\",\n \"description\": \"Description\",\n \"contentPreview\": \"Content Preview\",\n \"enabledWarning\": \"After import, this prompt will be enabled immediately and other prompts will be disabled\"\n },\n \"skill\": {\n \"title\": \"Add Claude Skill Repository\",\n \"repo\": \"GitHub Repository\",\n \"directory\": \"Target Directory\",\n \"branch\": \"Branch\",\n \"skillsPath\": \"Skills Path\",\n \"hint\": \"This will add the Skill repository to the list.\",\n \"hintDetail\": \"After adding, you can install specific Skills from the Skills management page.\"\n },\n \"usageScript\": \"Usage Query\",\n \"usageScriptEnabled\": \"Enabled\",\n \"usageScriptDisabled\": \"Disabled\",\n \"usageApiKey\": \"Usage API Key\",\n \"usageBaseUrl\": \"Usage Query URL\",\n \"usageAutoInterval\": \"Auto Query\",\n \"usageAutoIntervalValue\": \"Every {{minutes}} minutes\"\n },\n \"iconPicker\": {\n \"search\": \"Search Icons\",\n \"searchPlaceholder\": \"Enter icon name...\",\n \"noResults\": \"No matching icons found\",\n \"category\": {\n \"aiProvider\": \"AI Providers\",\n \"cloud\": \"Cloud Platforms\",\n \"tool\": \"Dev Tools\",\n \"other\": \"Other\"\n }\n },\n \"providerIcon\": {\n \"label\": \"Icon\",\n \"colorLabel\": \"Icon Color\",\n \"selectIcon\": \"Select Icon\",\n \"preview\": \"Preview\",\n \"clickToChange\": \"Click to change icon\",\n \"clickToSelect\": \"Click to select icon\",\n \"color\": \"Icon Color\"\n },\n \"migration\": {\n \"success\": \"Configuration migrated successfully\",\n \"skillsSuccess\": \"Automatically imported {{count}} skill(s) into unified management\",\n \"skillsFailed\": \"Failed to auto import skills\",\n \"skillsFailedDescription\": \"Open the Skills page and click \\\"Import Existing\\\" to import manually (or restart and try again).\"\n },\n \"agents\": {\n \"title\": \"Agents\"\n },\n \"health\": {\n \"operational\": \"Operational\",\n \"degraded\": \"Degraded\",\n \"failed\": \"Failed\",\n \"circuitOpen\": \"Circuit Open\",\n \"consecutiveFailures\": \"{{count}} consecutive failures\"\n },\n \"failover\": {\n \"enabled\": \"{{app}} failover enabled\",\n \"disabled\": \"{{app}} failover disabled\",\n \"toggleFailed\": \"Operation failed: {{detail}}\",\n \"inQueue\": \"In queue\",\n \"addQueue\": \"Add\",\n \"priority\": {\n \"tooltip\": \"Failover priority {{priority}}\"\n },\n \"tooltip\": {\n \"enabled\": \"{{app}} failover enabled\\nRequests follow queue priority (P1→P2→...)\",\n \"disabled\": \"Enable {{app}} failover\\nSwitches to queue P1 immediately, then falls back on failures\"\n }\n },\n \"proxy\": {\n \"panel\": {\n \"serviceAddress\": \"Service Address\",\n \"addressCopied\": \"Address copied\",\n \"currentProvider\": \"Current Provider:\",\n \"waitingFirstRequest\": \"Current Provider: Waiting for first request...\",\n \"stoppedTitle\": \"Routing Service Stopped\",\n \"stoppedDescription\": \"Use the toggle above to start the service\",\n \"openSettings\": \"Configure Routing Service\",\n \"stats\": {\n \"activeConnections\": \"Active Connections\",\n \"totalRequests\": \"Total Requests\",\n \"successRate\": \"Success Rate\",\n \"uptime\": \"Uptime\"\n }\n },\n \"settings\": {\n \"title\": \"Routing Service Settings\",\n \"description\": \"Configure local routing server listening address, port and runtime parameters. Changes take effect immediately after saving.\",\n \"alert\": {\n \"autoApply\": \"Changes will be automatically synced to the running routing service without manual restart.\"\n },\n \"basic\": {\n \"title\": \"Basic Settings\",\n \"description\": \"Configure routing service listening address and port.\"\n },\n \"advanced\": {\n \"title\": \"Advanced Parameters\",\n \"description\": \"Control request stability and logging.\"\n },\n \"timeout\": {\n \"title\": \"Timeout Settings\",\n \"description\": \"Configure timeout for streaming and non-streaming requests.\"\n },\n \"fields\": {\n \"listenAddress\": {\n \"label\": \"Listen Address\",\n \"placeholder\": \"127.0.0.1\",\n \"description\": \"IP address the routing server listens on (recommended: 127.0.0.1)\"\n },\n \"listenPort\": {\n \"label\": \"Listen Port\",\n \"placeholder\": \"15721\",\n \"description\": \"Port number the routing server listens on (1024 ~ 65535)\"\n },\n \"maxRetries\": {\n \"label\": \"Max Retries\",\n \"placeholder\": \"3\",\n \"description\": \"Number of retries on request failure (0 ~ 10)\"\n },\n \"requestTimeout\": {\n \"label\": \"Request Timeout (sec)\",\n \"placeholder\": \"0 (unlimited) or 300\",\n \"description\": \"Maximum wait time for a single request (0 = unlimited, or 10 ~ 600 seconds)\"\n },\n \"enableLogging\": {\n \"label\": \"Enable Logging\",\n \"description\": \"Log all routing requests for troubleshooting\"\n },\n \"streamingFirstByteTimeout\": {\n \"label\": \"Streaming First Byte Timeout (sec)\",\n \"description\": \"Maximum time to wait for the first data chunk\"\n },\n \"streamingIdleTimeout\": {\n \"label\": \"Streaming Idle Timeout (sec)\",\n \"description\": \"Maximum interval between data chunks\"\n },\n \"nonStreamingTimeout\": {\n \"label\": \"Non-Streaming Timeout (sec)\",\n \"description\": \"Total timeout for non-streaming requests\"\n }\n },\n \"validation\": {\n \"addressInvalid\": \"Please enter a valid IP address\",\n \"portMin\": \"Port must be greater than 1024\",\n \"portMax\": \"Port must be less than 65535\",\n \"retryMin\": \"Retry count cannot be negative\",\n \"retryMax\": \"Retry count cannot exceed 10\",\n \"timeoutNonNegative\": \"Timeout cannot be negative\",\n \"timeoutMax\": \"Timeout cannot exceed 600 seconds\",\n \"timeoutRange\": \"Please enter 0 or a value between 10-600\",\n \"streamingTimeoutMin\": \"Timeout must be at least 5 seconds\",\n \"streamingTimeoutMax\": \"Timeout cannot exceed 300 seconds\"\n },\n \"actions\": {\n \"save\": \"Save Configuration\"\n },\n \"toast\": {\n \"saved\": \"Routing configuration saved\",\n \"saveFailed\": \"Save failed: {{error}}\"\n },\n \"invalidPort\": \"Invalid port, please enter a number between 1024-65535\",\n \"invalidAddress\": \"Invalid address. Enter an IPv4 (e.g. 127.0.0.1), IPv6 (e.g. ::1), or localhost\",\n \"configSaved\": \"Routing configuration saved\",\n \"configSaveFailed\": \"Failed to save configuration\",\n \"restartRequired\": \"Restart routing service for address or port changes to take effect\"\n },\n \"switchFailed\": \"Switch failed: {{error}}\",\n \"takeover\": {\n \"hint\": \"Select apps to route — once enabled, requests from that app will go through local routing\",\n \"enabled\": \"{{app}} routing enabled\",\n \"disabled\": \"{{app}} routing disabled\",\n \"failed\": \"Failed to toggle routing: {{detail}}\",\n \"tooltip\": {\n \"active\": \"{{appLabel}} is routing - {{address}}:{{port}}\\nSwitch provider for hot switching\",\n \"broken\": \"{{appLabel}} is routing, but routing service is not running\",\n \"inactive\": \"Route {{appLabel}}'s requests through local routing\"\n }\n },\n \"failover\": {\n \"proxyRequired\": \"Routing service must be started to configure failover\",\n \"autoSwitch\": \"Auto Failover\",\n \"autoSwitchDescription\": \"When enabled, switches to queue P1 immediately and automatically tries the next provider in the queue on failures\"\n },\n \"failoverQueue\": {\n \"title\": \"Failover Queue\",\n \"description\": \"Manage failover order for each app's providers\",\n \"info\": \"When auto failover is enabled, requests follow the queue priority order (P1 first). On failures, the system will try the next provider in the queue.\",\n \"selectProvider\": \"Select a provider to add to queue\",\n \"noAvailableProviders\": \"No providers available to add\",\n \"empty\": \"Failover queue is empty. Add providers to enable automatic failover.\",\n \"orderHint\": \"Queue order matches the provider list order on the Home page. Reorder providers on the Home page to change priority.\",\n \"dragHint\": \"Drag providers to adjust failover order. Lower numbers have higher priority.\",\n \"toggleEnabled\": \"Enable/Disable\",\n \"addSuccess\": \"Added to failover queue\",\n \"addFailed\": \"Failed to add\",\n \"removeSuccess\": \"Removed from failover queue\",\n \"removeFailed\": \"Failed to remove\",\n \"reorderSuccess\": \"Queue order updated\",\n \"reorderFailed\": \"Failed to update order\",\n \"toggleFailed\": \"Failed to update status\"\n },\n \"autoFailover\": {\n \"info\": \"When the failover queue has multiple providers, the system will try them in priority order when requests fail. When a provider reaches the consecutive failure threshold, the circuit breaker will open and skip it temporarily.\",\n \"configSaved\": \"Auto failover config saved\",\n \"configSaveFailed\": \"Failed to save\",\n \"validationFailed\": \"The following fields are out of valid range: {{fields}}\",\n \"retrySettings\": \"Retry & Timeout Settings\",\n \"failureThreshold\": \"Failure Threshold\",\n \"failureThresholdHint\": \"Open circuit breaker after this many consecutive failures (recommended: 3-10)\",\n \"timeout\": \"Recovery Wait Time (seconds)\",\n \"timeoutHint\": \"Wait this long before trying to recover after circuit opens (recommended: 30-120)\",\n \"circuitBreakerSettings\": \"Circuit Breaker Settings\",\n \"successThreshold\": \"Recovery Success Threshold\",\n \"successThresholdHint\": \"Close circuit breaker after this many successes in half-open state\",\n \"errorRate\": \"Error Rate Threshold (%)\",\n \"errorRateHint\": \"Open circuit breaker when error rate exceeds this value\",\n \"minRequests\": \"Minimum Requests\",\n \"minRequestsHint\": \"Minimum requests before calculating error rate\",\n \"explanationTitle\": \"How It Works\",\n \"failureThresholdLabel\": \"Failure Threshold\",\n \"failureThresholdExplain\": \"Circuit breaker opens after this many consecutive failures, making the provider temporarily unavailable\",\n \"timeoutLabel\": \"Recovery Wait Time\",\n \"timeoutExplain\": \"After circuit opens, wait this long before trying half-open state\",\n \"successThresholdLabel\": \"Recovery Success Threshold\",\n \"successThresholdExplain\": \"In half-open state, close circuit breaker after this many successes, making provider available again\",\n \"errorRateLabel\": \"Error Rate Threshold\",\n \"errorRateExplain\": \"Open circuit breaker when error rate exceeds this value, even if failure threshold not reached\",\n \"maxRetries\": \"Max Retries\",\n \"timeoutSettings\": \"Timeout Settings\",\n \"streamingFirstByte\": \"Streaming First Byte Timeout\",\n \"streamingIdle\": \"Streaming Idle Timeout\",\n \"nonStreaming\": \"Non-Streaming Timeout\",\n \"maxRetriesHint\": \"Number of retries on request failure (0-10)\",\n \"streamingFirstByteHint\": \"Max time to wait for first data chunk, range 1-120s, default 60s\",\n \"streamingIdleHint\": \"Max interval between data chunks, range 60-600s, 0 to disable (prevents mid-stream stalls)\",\n \"nonStreamingHint\": \"Total timeout for non-streaming requests, range 60-1200s, default 600s (10 min)\"\n },\n \"logging\": {\n \"enabled\": \"Logging enabled\",\n \"disabled\": \"Logging disabled\",\n \"failed\": \"Failed to toggle logging\"\n },\n \"server\": {\n \"started\": \"Routing service started - {{address}}:{{port}}\",\n \"startFailed\": \"Failed to start routing service: {{detail}}\"\n },\n \"stoppedWithRestore\": \"Routing service stopped, all routing configs restored\",\n \"stopWithRestoreFailed\": \"Stop failed: {{detail}}\"\n },\n \"streamCheck\": {\n \"reachable\": \"{{providerName}} is reachable ({{responseTimeMs}}ms)\",\n \"reachableSlow\": \"{{providerName}} reachable but slow ({{responseTimeMs}}ms)\",\n \"unreachable\": \"{{providerName}} unreachable: {{message}}\",\n \"unreachableHint\": \"Could not establish a connection (DNS / connect / TLS / timeout). Check the base_url and your network.\",\n \"connectivityNote\": \"Connectivity check only probes whether the provider address is reachable; it does not send a real model request. Any response counts as \\\"reachable\\\" — which does not guarantee that auth or model settings are correct.\",\n \"configSaved\": \"Connectivity check settings saved\",\n \"configSaveFailed\": \"Save failed\",\n \"checkParams\": \"Check Parameters\",\n \"timeout\": \"Timeout (seconds)\",\n \"maxRetries\": \"Max Retries\",\n \"degradedThreshold\": \"Slow-response threshold (ms)\",\n \"error\": \"{{providerName}} check error: {{error}}\"\n },\n \"proxyConfig\": {\n \"proxyEnabled\": \"Routing Master Switch\",\n \"appTakeover\": \"Routing Enabled\",\n \"perAppConfig\": \"Per-App Config\",\n \"circuitBreaker\": \"Circuit Breaker\",\n \"circuitBreakerSettings\": \"Circuit Breaker Settings\",\n \"failureThreshold\": \"Failure Threshold\",\n \"successThreshold\": \"Success Threshold\",\n \"recoveryTimeout\": \"Recovery Timeout\",\n \"errorRateThreshold\": \"Error Rate Threshold\",\n \"minRequests\": \"Min Requests\",\n \"timeoutConfig\": \"Timeout Config\",\n \"streamingFirstByte\": \"Streaming First Byte Timeout\",\n \"streamingIdle\": \"Streaming Idle Timeout\",\n \"nonStreaming\": \"Non-Streaming Timeout\"\n },\n \"circuitBreaker\": {\n \"failureThreshold\": \"Failure Threshold\",\n \"successThreshold\": \"Success Threshold\",\n \"timeoutSeconds\": \"Timeout (seconds)\",\n \"errorRateThreshold\": \"Error Rate Threshold (%)\",\n \"minRequests\": \"Minimum Requests\",\n \"validationFailed\": \"The following fields are out of valid range: {{fields}}\",\n \"configSaved\": \"Circuit breaker configuration saved\",\n \"saveFailed\": \"Save failed\",\n \"loading\": \"Loading...\",\n \"title\": \"Circuit Breaker Configuration\",\n \"description\": \"Adjust circuit breaker parameters to control fault detection and recovery behavior\",\n \"failureThresholdHint\": \"How many consecutive failures trigger the circuit breaker\",\n \"timeoutSecondsHint\": \"How long to wait before attempting recovery (half-open state)\",\n \"successThresholdHint\": \"How many successes in half-open state to close the circuit breaker\",\n \"errorRateThresholdHint\": \"Open circuit breaker when error rate exceeds this value\",\n \"minRequestsHint\": \"Minimum requests before calculating error rate\",\n \"saveConfig\": \"Save Configuration\",\n \"instructionsTitle\": \"Configuration Instructions\",\n \"instructions\": {\n \"failureThreshold\": \"Circuit breaker opens when consecutive failures reach this count\",\n \"timeout\": \"After circuit breaker opens, wait this time before attempting half-open\",\n \"successThreshold\": \"In half-open state, close circuit breaker when successes reach this count\",\n \"errorRate\": \"Circuit breaker opens when error rate exceeds this value\",\n \"minRequests\": \"Error rate is only calculated after request count reaches this value\"\n }\n },\n \"universalProvider\": {\n \"duplicate\": \"Duplicate\",\n \"duplicatedAndSynced\": \"Universal provider duplicated and synced\",\n \"duplicateError\": \"Failed to duplicate universal provider\",\n \"title\": \"Universal Provider\",\n \"description\": \"Universal providers manage Claude, Codex, and Gemini configurations simultaneously. Changes are automatically synced to all enabled apps.\",\n \"add\": \"Add Universal Provider\",\n \"edit\": \"Edit Universal Provider\",\n \"empty\": \"No universal providers yet\",\n \"emptyHint\": \"Click the \\\"Add Universal Provider\\\" button below to create one\",\n \"selectPreset\": \"Select Preset Type\",\n \"name\": \"Name\",\n \"namePlaceholder\": \"e.g., My NewAPI\",\n \"baseUrl\": \"API URL\",\n \"apiKey\": \"API Key\",\n \"websiteUrl\": \"Website URL\",\n \"websiteUrlPlaceholder\": \"https://example.com (optional, displayed in the list)\",\n \"notes\": \"Notes\",\n \"notesPlaceholder\": \"Optional: Add notes\",\n \"enabledApps\": \"Enabled Apps\",\n \"modelConfig\": \"Model Configuration\",\n \"model\": \"Model\",\n \"sync\": \"Sync to Apps\",\n \"synced\": \"Synced to all apps\",\n \"syncError\": \"Sync failed\",\n \"noAppsEnabled\": \"No apps enabled\",\n \"added\": \"Universal provider added\",\n \"addedAndSynced\": \"Universal provider added and synced\",\n \"addedButSyncFailed\": \"Universal provider added, but sync failed\",\n \"updated\": \"Universal provider updated\",\n \"deleted\": \"Universal provider deleted\",\n \"addFailed\": \"Failed to add universal provider\",\n \"hint\": \"Cross-app unified config, auto-sync to Claude/Codex/Gemini\",\n \"manage\": \"Manage\",\n \"loadError\": \"Failed to load universal providers\",\n \"saveError\": \"Failed to save universal provider\",\n \"deleteError\": \"Failed to delete universal provider\",\n \"deleteConfirmTitle\": \"Delete Universal Provider\",\n \"deleteConfirmDescription\": \"Are you sure you want to delete \\\"{{name}}\\\"? This will also delete its generated provider configurations in each app.\",\n \"syncConfirmTitle\": \"Sync Universal Provider\",\n \"syncConfirmDescription\": \"Syncing \\\"{{name}}\\\" will overwrite the associated provider configurations in Claude, Codex, and Gemini. Do you want to continue?\",\n \"syncConfirm\": \"Sync\",\n \"saveAndSync\": \"Save & Sync\",\n \"savedAndSynced\": \"Saved and synced to all apps\",\n \"saveAndSyncError\": \"Failed to save and sync\",\n \"configJsonPreview\": \"Config JSON Preview\",\n \"configJsonPreviewHint\": \"The following configurations will be synced to each app (only the displayed fields will be overwritten, other custom settings will be preserved)\"\n },\n \"omo\": {\n \"editProfile\": \"Edit OMO Config\",\n \"newProfile\": \"New OMO Config\",\n \"profileName\": \"Name\",\n \"mainAgents\": \"Main Agents\",\n \"subAgents\": \"Sub Agents\",\n \"categories\": \"Categories\",\n \"customAgents\": \"Custom Agents\",\n \"noCustomAgents\": \"No custom agents\",\n \"otherFields\": \"Other Config\",\n \"globalConfig\": \"OMO Global Config\",\n \"globalConfigShort\": \"OMO Config\",\n \"globalConfigSaved\": \"Global config saved\",\n \"addProfile\": \"Add OMO Provider\",\n \"disabledItems\": \"Disabled Items\",\n \"advanced\": \"Advanced Settings\",\n \"profileCreated\": \"OMO config created\",\n \"profileUpdated\": \"OMO config updated\",\n \"invalidJson\": \"Other Fields contains invalid JSON\",\n \"confirmDelete\": \"Delete Config\",\n \"confirmDeleteMsg\": \"Delete \\\"{{name}}\\\"?\",\n \"profileDeleted\": \"Config deleted\",\n \"imported\": \"Imported as \\\"{{name}}\\\"\",\n \"import\": \"Import\",\n \"global\": \"Global\",\n \"empty\": \"No OMO configs yet. Click + Add or Import from local.\",\n \"applied\": \"Applied\",\n \"apply\": \"Apply\",\n \"enable\": \"Enable\",\n \"enabled\": \"Enabled\",\n \"disabled\": \"OMO disabled\",\n \"disableFailed\": \"Failed to disable OMO: {{error}}\",\n \"selectPlaceholder\": \"Select...\",\n \"clear\": \"Clear\",\n \"clearWrapped\": \"(Clear)\",\n \"defaultWrapped\": \"(Default)\",\n \"variantPlaceholder\": \"variant\",\n \"selectEnabledModel\": \"Select configured model\",\n \"selectModel\": \"Select configured model\",\n \"recommendedHint\": \"Recommended: {{model}}\",\n \"searchModel\": \"Search model...\",\n \"selectModelFirst\": \"Select model first\",\n \"noEnabledModels\": \"No configured models\",\n \"noVariantsForModel\": \"No variants for model\",\n \"currentValueNotEnabled\": \"{{value}} (current value, not configured)\",\n \"currentValueUnavailable\": \"{{value}} (current value, unavailable)\",\n \"advancedLabel\": \"Advanced\",\n \"advancedJsonInvalid\": \"Advanced JSON is invalid\",\n \"advancedJsonHint\": \"temperature, top_p, budgetTokens, prompt_append, permission, etc. Leave empty for defaults\",\n \"noEnabledModelsWarning\": \"No configured models available. Configure OpenCode models first.\",\n \"modelSourcePartialWarning\": \"Some provider model configs are invalid and were skipped.\",\n \"modelSourceFallbackWarning\": \"Failed to load live provider state. Falling back to configured providers.\",\n \"importLocalReplaceSuccess\": \"Imported local file and replaced Agents/Categories/Other Fields\",\n \"importLocalFailed\": \"Failed to read local file: {{error}}\",\n \"agentKeyPlaceholder\": \"agent key\",\n \"categoryKeyPlaceholder\": \"category key\",\n \"modelNamePlaceholder\": \"model-name\",\n \"custom\": \"Custom\",\n \"customCategories\": \"Custom Categories\",\n \"modelConfiguration\": \"Model Configuration\",\n \"fillRecommended\": \"Fill Recommended\",\n \"fillRecommendedSuccess\": \"Filled {{count}} recommended models\",\n \"fillRecommendedPartial\": \"Filled {{filled}} recommended models, {{unmatched}} unmatched\",\n \"fillRecommendedMostlyUnmatched\": \"Filled only {{filled}}, {{unmatched}} unmatched (e.g. {{examples}}). Configure providers offering these models or pick a substitute.\",\n \"fillRecommendedAllSet\": \"All slots already have models configured\",\n \"fillRecommendedNoMatch\": \"Recommended models not found in configured providers (e.g. {{examples}})\",\n \"configSummary\": \"{{agents}} agents, {{categories}} categories configured · Click ⚙ for advanced params\",\n \"enabledModelsCount\": \"{{count}} configured models available\",\n \"source\": \"from:\",\n \"otherFieldsJson\": \"Other Fields (JSON)\",\n \"slimOtherFieldsHint\": \"Use this area for top-level OMO Slim config such as council, fallback, multiplexer, disabled_mcps, and todoContinuation.\",\n \"searchOrType\": \"Search or type custom value...\",\n \"noMatches\": \"No matches\",\n \"jsonMustBeObject\": \"{{field}} must be a JSON object\",\n \"jsonInvalid\": \"{{field}} contains invalid JSON\",\n \"importGlobalSuccess\": \"Imported global config from local file (unsaved)\",\n \"importGlobalFailed\": \"Failed to read local file: {{error}}\",\n \"importLocal\": \"Import Local\",\n \"saveGlobalConfig\": \"Save Global Config\",\n \"schemaUrl\": \"$schema\",\n \"resetDefault\": \"Reset\",\n \"sisyphusAgentConfig\": \"Sisyphus Agent Config\",\n \"disabledAgents\": \"Agents\",\n \"disabledAgentsPlaceholder\": \"Disabled Agents\",\n \"disabledMcps\": \"MCPs\",\n \"disabledMcpsPlaceholder\": \"Disabled MCPs\",\n \"disabledHooks\": \"Hooks\",\n \"disabledHooksPlaceholder\": \"Disabled Hooks\",\n \"disabledSkills\": \"Skills\",\n \"disabledSkillsPlaceholder\": \"Disabled Skills\",\n \"advancedLsp\": \"LSP Config\",\n \"advancedExperimental\": \"Experimental Features\",\n \"advancedBackgroundTask\": \"Background Tasks\",\n \"advancedBrowserAutomation\": \"Browser Automation\",\n \"advancedClaudeCode\": \"Claude Code\",\n \"agentDesc\": {\n \"sisyphus\": \"Main orchestrator\",\n \"hephaestus\": \"Autonomous deep worker\",\n \"prometheus\": \"Strategic planner\",\n \"atlas\": \"Task manager\",\n \"oracle\": \"Strategic advisor\",\n \"librarian\": \"Multi-repo researcher\",\n \"explore\": \"Fast code search\",\n \"multimodalLooker\": \"Media analyzer\",\n \"metis\": \"Pre-plan analysis advisor\",\n \"momus\": \"Plan reviewer\",\n \"sisyphusJunior\": \"Delegated task executor\"\n },\n \"agentTooltip\": {\n \"sisyphus\": \"Main orchestrator responsible for task planning, delegation and parallel execution. Uses extended thinking (32k budget) and drives workflows through TODO lists to ensure task completion.\",\n \"atlas\": \"Main orchestrator (holds TODO list) responsible for task distribution and coordination during execution phase. Delegates to specialized agents rather than completing all work directly.\",\n \"prometheus\": \"Strategic planner that collects requirements through interview mode and creates detailed work plans. Can only read/write Markdown files in .sisyphus/ directory, never writes code directly.\",\n \"hephaestus\": \"Autonomous deep worker (\\\"legitimate craftsman\\\") inspired by AmpCode deep mode. Goal-oriented execution that launches 2-5 explore/librarian agents in parallel for research before taking action.\",\n \"oracle\": \"Architecture decision and debugging advisor. Read-only consulting agent providing excellent logical reasoning and deep analysis. Cannot write files or delegate tasks.\",\n \"librarian\": \"Multi-repository analysis and documentation retrieval expert. Deeply understands codebases and provides evidence-based answers. Excels at finding official documentation and open-source implementation examples.\",\n \"explore\": \"Fast codebase exploration and context grep expert. Uses lightweight models for high-speed search. The scout for understanding code structure.\",\n \"multimodalLooker\": \"Visual content expert that analyzes PDFs, images, charts and other non-text media, extracting information and insights from them.\",\n \"metis\": \"Plan consultant that performs pre-analysis before planning. Identifies hidden intents, ambiguities and AI failure points to prevent over-engineering.\",\n \"momus\": \"Plan reviewer that validates plan clarity, verifiability and completeness with high precision. Rejects and requests revisions until plans are perfect.\",\n \"sisyphusJunior\": \"Category-generated executor, automatically created via category parameters. Focuses on executing assigned tasks and cannot re-delegate, preventing infinite delegation loops.\"\n },\n \"categoryDesc\": {\n \"visualEngineering\": \"Visual/frontend engineering\",\n \"ultrabrain\": \"Ultra thinking\",\n \"deep\": \"Deep work\",\n \"artistry\": \"Creative/artistic\",\n \"quick\": \"Quick response\",\n \"unspecifiedLow\": \"General low tier\",\n \"unspecifiedHigh\": \"General high tier\",\n \"writing\": \"Writing\"\n },\n \"categoryTooltip\": {\n \"visualEngineering\": \"Frontend and visual engineering category for UI/UX design, styling, animation and interface implementation. Defaults to Gemini 3.5 Flash model.\",\n \"ultrabrain\": \"Deep logical reasoning category for complex architectural decisions requiring extensive analysis. Defaults to GPT-5.3 Codex ultra-high reasoning variant.\",\n \"deep\": \"Deep autonomous problem-solving category with goal-oriented execution. Conducts thorough research before action, suitable for difficult problems requiring deep understanding.\",\n \"artistry\": \"Highly creative and artistic task category that inspires novel ideas and creative solutions. Defaults to Gemini 3.5 Flash high variant.\",\n \"quick\": \"Lightweight task category for single-file modifications, typo fixes, and simple adjustments. Defaults to Claude Haiku 4.5 fast model.\",\n \"unspecifiedLow\": \"Uncategorized low-effort task category for tasks that don't fit other categories with small workload. Defaults to Claude Sonnet 4.5.\",\n \"unspecifiedHigh\": \"Uncategorized high-effort task category for tasks that don't fit other categories with large workload. Defaults to Claude Opus 4.8 max variant.\",\n \"writing\": \"Writing category for documentation, prose and technical writing. Defaults to Kimi K2.5 model.\"\n },\n \"slimAgentDesc\": {\n \"orchestrator\": \"Orchestrator\",\n \"oracle\": \"Oracle\",\n \"librarian\": \"Librarian\",\n \"explorer\": \"Explorer\",\n \"designer\": \"Designer\",\n \"fixer\": \"Fixer\",\n \"council\": \"Council\"\n },\n \"slimAgentTooltip\": {\n \"orchestrator\": \"Writes executable code, orchestrates multi-agent workflow, summons experts\",\n \"oracle\": \"Root cause analysis, architecture review, debugging guidance (read-only)\",\n \"librarian\": \"Documentation lookup, GitHub code search (read-only)\",\n \"explorer\": \"Regex search, AST pattern matching, file discovery (read-only)\",\n \"designer\": \"Modern responsive design, CSS/Tailwind expertise\",\n \"fixer\": \"Code implementation, refactoring, testing, verification\",\n \"council\": \"Multi-model consensus agent. Runs multiple read-only councillors in parallel, then lets the master synthesize the final answer.\"\n }\n },\n \"openclawConfig\": {\n \"defaultModel\": {\n \"title\": \"Default Model\",\n \"description\": \"Configure the default primary model and fallback models for OpenClaw\",\n \"primary\": \"Primary Model\",\n \"primaryPlaceholder\": \"e.g., deepseek/deepseek-chat\",\n \"fallbacks\": \"Fallback Models\",\n \"fallbacksPlaceholder\": \"e.g., openrouter/anthropic/claude-sonnet-4.5\",\n \"addFallback\": \"Add Fallback Model\",\n \"saved\": \"Default model configuration saved\",\n \"saveFailed\": \"Failed to save default model\"\n },\n \"modelCatalog\": {\n \"title\": \"Model Catalog\",\n \"description\": \"Configure model aliases and allowlist\",\n \"modelId\": \"Model ID\",\n \"modelIdPlaceholder\": \"e.g., deepseek/deepseek-chat\",\n \"alias\": \"Alias\",\n \"aliasPlaceholder\": \"e.g., DeepSeek\",\n \"addEntry\": \"Add Model\",\n \"removeEntry\": \"Remove\",\n \"saved\": \"Model catalog saved\",\n \"saveFailed\": \"Failed to save model catalog\",\n \"empty\": \"No model catalog entries\",\n \"emptyHint\": \"Add models to the catalog to configure aliases\"\n },\n \"suggestedDefaults\": \"Apply Suggested Defaults\",\n \"suggestedDefaultsHint\": \"Use this preset's recommended default model configuration\"\n },\n \"subscription\": {\n \"title\": \"Subscription Quota\",\n \"fiveHour\": \"5-Hour\",\n \"sevenDay\": \"7-Day\",\n \"sevenDayOpus\": \"7-Day (Opus)\",\n \"sevenDaySonnet\": \"7-Day (Sonnet)\",\n \"thirtyDay\": \"30-Day\",\n \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"Weekly\",\n \"monthly\": \"Monthly\",\n \"copilotPremium\": \"Premium\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"Resets in {{time}}\",\n \"extraUsage\": \"Extra Usage\",\n \"expired\": \"Session expired\",\n \"expiredHint\": \"Run the {{tool}} command to refresh your login\",\n \"queryFailed\": \"Query failed\",\n \"refresh\": \"Refresh\"\n }\n}\n"}}, "src/i18n/locales/zh-TW.json": {"1e398a3709f03db3fe5d989827090386b9dcd3be": {"t": 1785674717.9934938, "n": 109182, "h": "786d9022f8f94292", "c": "{\n \"app\": {\n \"title\": \"CC Switch\",\n \"description\": \"Claude Code / Codex / Gemini CLI 全方位輔助工具\"\n },\n \"common\": {\n \"add\": \"新增\",\n \"edit\": \"編輯\",\n \"delete\": \"刪除\",\n \"save\": \"儲存\",\n \"saving\": \"儲存中...\",\n \"cancel\": \"取消\",\n \"confirm\": \"確認\",\n \"close\": \"關閉\",\n \"done\": \"完成\",\n \"settings\": \"設定\",\n \"about\": \"關於\",\n \"version\": \"版本\",\n \"loading\": \"載入中...\",\n \"notInstalled\": \"未安裝\",\n \"success\": \"成功\",\n \"error\": \"錯誤\",\n \"unknown\": \"未知\",\n \"enterValidValue\": \"請輸入有效的內容\",\n \"clear\": \"清除\",\n \"toggleTheme\": \"切換主題\",\n \"format\": \"格式化\",\n \"formatSuccess\": \"格式化成功\",\n \"formatError\": \"格式化失敗:{{error}}\",\n \"copy\": \"複製\",\n \"view\": \"檢視\",\n \"back\": \"返回\",\n \"refresh\": \"重新整理\",\n \"refreshing\": \"重新整理中...\",\n \"import\": \"匯入\",\n \"all\": \"全部\",\n \"search\": \"搜尋\",\n \"reset\": \"重設\",\n \"actions\": \"操作\",\n \"deleting\": \"刪除中...\",\n \"auto\": \"自動\",\n \"enabled\": \"已啟用\",\n \"notSet\": \"未設定\"\n },\n \"firstRunNotice\": {\n \"title\": \"歡迎使用 CC Switch\",\n \"bodyDefault\": \"CC Switch 可以協助您在 Claude Code / Codex / Gemini CLI 的多個供應商之間一鍵切換。如果您之前已經設定過這些工具,CC Switch 會自動將現有設定儲存為名為 default 的供應商,確保您的設定不會遺失。\",\n \"bodyOfficial\": \"清單中還預設了「官方 (Official)」供應商,隨時點擊即可切換回官方預設設定。切換前 CC Switch 會自動將目前設定備份回 default,可以放心來回切換。這就是 CC Switch 的運作方式。\",\n \"confirm\": \"我了解了\"\n },\n \"apiKeyInput\": {\n \"placeholder\": \"請輸入 API Key\",\n \"show\": \"顯示 API Key\",\n \"hide\": \"隱藏 API Key\"\n },\n \"jsonEditor\": {\n \"mustBeObject\": \"設定必須是 JSON 物件,不能是陣列或其他類型\",\n \"invalidJson\": \"JSON 格式錯誤\"\n },\n \"commonConfig\": {\n \"guideTitle\": \"什麼是通用設定片段?\",\n \"guidePurpose\": \"用來在不同供應商之間共用外掛程式、環境變數等非敏感設定。切換供應商時不會遺失這些設定。\",\n \"guideUsage\": \"用法:① 點擊「從編輯內容擷取」儲存通用部分 ② 新增供應商時勾選「套用通用設定」\",\n \"guideReExtract\": \"如果您新安裝了外掛程式或 Hook,請重新擷取一次通用設定,以便同步至其他供應商。\",\n \"guideReassurance\": \"請放心:您的原始設定已儲存在預設供應商中,不會遺失。\",\n \"emptyTitle\": \"尚未建立通用設定片段\",\n \"emptyHint\": \"點擊下方「從編輯內容擷取」按鈕,從目前設定中擷取可重複使用的部分\"\n },\n \"claudeConfig\": {\n \"configLabel\": \"Claude Code 設定 (JSON) *\",\n \"writeCommonConfig\": \"套用通用設定\",\n \"editCommonConfig\": \"編輯通用設定\",\n \"editCommonConfigTitle\": \"編輯通用設定片段\",\n \"commonConfigHint\": \"該片段會在勾選「套用通用設定」時合併到 settings.json 中\",\n \"fullSettingsHint\": \"完整的 Claude Code settings.json 設定內容\",\n \"extractFromCurrent\": \"從編輯內容擷取\",\n \"extractNoCommonConfig\": \"目前編輯內容沒有可擷取的通用設定\",\n \"extractFailed\": \"擷取失敗:{{error}}\",\n \"saveFailed\": \"儲存失敗:{{error}}\",\n \"hideAttribution\": \"隱藏 AI 署名\",\n \"enableTeammates\": \"Teammates 模式\",\n \"enableToolSearch\": \"啟用 Tool Search\",\n \"effortMax\": \"最大強度思考\",\n \"disableAutoUpgrade\": \"停用自動升級\"\n },\n \"header\": {\n \"viewOnGithub\": \"在 GitHub 上檢視\",\n \"toggleDarkMode\": \"切換至深色模式\",\n \"toggleLightMode\": \"切換至淺色模式\",\n \"addProvider\": \"新增供應商\",\n \"switchToChinese\": \"切換至中文\",\n \"switchToEnglish\": \"切換至英文\",\n \"enterEditMode\": \"進入編輯模式\",\n \"exitEditMode\": \"退出編輯模式\",\n \"windowMinimize\": \"最小化視窗\",\n \"windowMaximize\": \"最大化視窗\",\n \"windowRestore\": \"還原視窗\",\n \"windowClose\": \"關閉視窗\"\n },\n \"provider\": {\n \"connectivityCheck\": \"檢測連通\",\n \"tabProvider\": \"供應商\",\n \"tabUniversal\": \"通用供應商\",\n \"noProviders\": \"尚未新增任何供應商\",\n \"noProvidersDescription\": \"如果您已有設定,請點擊「匯入目前設定」,所有資料將安全儲存在 default 供應商中\",\n \"noProvidersDescriptionSnippet\": \"除 API Key 和請求位址外的資料(如外掛程式設定)會被儲存至通用設定片段,用於在不同供應商之間共用\",\n \"importCurrent\": \"匯入目前設定\",\n \"importFromClaude\": \"將 Claude Code 中已有的供應商匯入\",\n \"importCurrentDescription\": \"將目前正在使用的設定匯入為預設供應商\",\n \"currentlyUsing\": \"目前使用\",\n \"enable\": \"啟用\",\n \"inUse\": \"使用中\",\n \"blockedByProxyHint\": \"代理接管模式下無法切換至官方供應商\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\n \"addFooterHint\": \"💡 選擇預設後,請在下方填寫 API Key 等欄位\",\n \"addClaudeProvider\": \"新增 Claude Code 供應商\",\n \"addCodexProvider\": \"新增 Codex 供應商\",\n \"addGeminiProvider\": \"新增 Gemini 供應商\",\n \"addOpenCodeProvider\": \"新增 OpenCode 供應商\",\n \"addToConfig\": \"新增\",\n \"removeFromConfig\": \"移除\",\n \"setAsDefault\": \"設為預設\",\n \"isDefault\": \"目前預設\",\n \"inConfig\": \"已新增\",\n \"addProviderHint\": \"填寫資訊後即可在清單中快速切換供應商。\",\n \"editClaudeProvider\": \"編輯 Claude Code 供應商\",\n \"editCodexProvider\": \"編輯 Codex 供應商\",\n \"configError\": \"設定錯誤\",\n \"notConfigured\": \"未設定官網位址\",\n \"applyToClaudePlugin\": \"套用至 Claude 外掛程式\",\n \"removeFromClaudePlugin\": \"從 Claude 外掛程式移除\",\n \"dragToReorder\": \"拖曳以重新排序\",\n \"dragHandle\": \"拖曳排序\",\n \"searchPlaceholder\": \"依名稱/備註/網址搜尋供應商...\",\n \"searchAriaLabel\": \"搜尋供應商\",\n \"searchScopeHint\": \"根據名稱、備註和官網連結比對結果。\",\n \"searchCloseHint\": \"按 Esc 關閉\",\n \"searchCloseAriaLabel\": \"關閉供應商搜尋\",\n \"noSearchResults\": \"沒有符合搜尋條件的供應商。\",\n \"duplicate\": \"複製\",\n \"sortUpdateFailed\": \"排序更新失敗\",\n \"configureUsage\": \"設定用量查詢\",\n \"officialPartner\": \"官方合作夥伴\",\n \"managedByHermes\": \"Hermes 託管\",\n \"managedByHermesHint\": \"該專案定義於 Hermes 的 providers: dict,請在 Hermes Web UI 中編輯或刪除。\",\n \"openTerminal\": \"開啟終端機\",\n \"terminalOpened\": \"終端機已開啟\",\n \"terminalOpenFailed\": \"開啟終端機失敗\",\n \"name\": \"供應商名稱\",\n \"namePlaceholder\": \"例如:Claude 官方\",\n \"websiteUrl\": \"官網連結\",\n \"notes\": \"備註\",\n \"notesPlaceholder\": \"例如:公司專用帳號\",\n \"configJson\": \"設定 JSON\",\n \"writeCommonConfig\": \"套用通用設定\",\n \"editCommonConfigButton\": \"編輯通用設定\",\n \"configJsonHint\": \"請填寫完整的 Claude Code 設定\",\n \"editCommonConfigTitle\": \"編輯通用設定片段\",\n \"editCommonConfigHint\": \"通用設定片段將合併至所有啟用它的供應商設定中\",\n \"addProvider\": \"新增供應商\",\n \"sortUpdated\": \"排序已更新\",\n \"usageSaved\": \"用量查詢設定已儲存\",\n \"usageSaveFailed\": \"用量查詢設定儲存失敗\",\n \"geminiConfig\": \"Gemini 設定\",\n \"geminiConfigHint\": \"使用 .env 格式設定 Gemini\",\n \"form\": {\n \"gemini\": {\n \"model\": \"模型\",\n \"oauthTitle\": \"OAuth 驗證模式\",\n \"oauthHint\": \"Google 官方使用 OAuth 個人驗證,無需填寫 API Key。首次使用時會自動開啟瀏覽器進行登入。\",\n \"apiKeyPlaceholder\": \"請輸入 Gemini API Key\"\n }\n }\n },\n \"claudeCode\": {\n \"needsRouting\": \"需要路由\",\n \"noRoutingSupport\": \"不支援路由\"\n },\n \"codex\": {\n \"needsRouting\": \"需要路由\",\n \"noRoutingSupport\": \"不支援路由\",\n \"officialRouting\": \"官方帳號路由\",\n \"nativeLogin\": \"Codex 登入\"\n },\n \"claudeDesktop\": {\n \"officialNotice\": \"Claude Desktop 官方供應商使用應用程式內建的 1P 登入,無需設定 API Key 和 API 位址。\",\n \"mode\": \"模型處理方式\",\n \"modeDirect\": \"直連\",\n \"modeProxy\": \"需要路由\",\n \"modelMappingToggle\": \"需要模型對應\",\n \"modelMappingOffHint\": \"僅當供應商直接接受 Claude Desktop 可識別的三檔角色 ID(claude-sonnet-* / claude-opus-* / claude-haiku-*)時才適用直連;其他模型名(含 claude-3-5-sonnet-… 等舊式 ID)請開啟此開關走對應。\",\n \"modelMappingOnHint\": \"Claude Desktop 只接受 claude-sonnet-* / claude-opus-* / claude-haiku-* 三檔角色 ID。開啟後 CC Switch 會把這三檔對應到供應商的實際模型,並在使用期間保持本地路由開啟。\",\n \"routeMapTitle\": \"模型對應\",\n \"routeMapHint\": \"為 Sonnet、Opus、Haiku 三檔分別填寫實際請求模型;選單顯示名稱可寫 DeepSeek、Kimi 等品牌名。留空的檔會自動沿用 Sonnet(或第一個已填檔)的模型,確保子 agent 呼叫的 Haiku 始終可用。\",\n \"routeModelLabel\": \"模型角色\",\n \"routeRoleSonnet\": \"Sonnet\",\n \"routeRoleOpus\": \"Opus\",\n \"routeRoleFable\": \"Fable\",\n \"routeRoleHaiku\": \"Haiku\",\n \"labelOverrideLabel\": \"選單顯示名稱\",\n \"upstreamModelLabel\": \"實際請求模型\",\n \"supports1mLabel\": \"聲明支援 1M\",\n \"supports1mShort\": \"1M\",\n \"directModelListTitle\": \"手動指定 Claude Desktop 模型清單(進階,選填)\",\n \"directModelListCollapsedHint\": \"原生 Claude 模型供應商通常不需填寫,Claude Desktop 會自動讀取 /v1/models。\",\n \"directModelListHint\": \"僅當供應商的 /v1/models 不可用或沒有回傳 Claude Desktop 可識別的 Sonnet / Opus / Haiku 模型名稱時填寫;勾選 1M 會向 Claude Desktop 聲明支援 1M 上下文。\",\n \"directModelInvalid\": \"直連模型必須使用 Claude Desktop 可識別的 Sonnet / Opus / Haiku 模型名稱\",\n \"addModel\": \"新增模型\",\n \"addRoute\": \"新增模型\",\n \"routeInvalid\": \"請選擇模型角色並填寫實際請求模型\",\n \"routesRequired\": \"至少填寫一個模型對應\",\n \"statusTitle\": \"Claude Desktop 設定需要檢查\",\n \"statusUnsupported\": \"目前平台暫不支援 Claude Desktop 3P 設定寫入。\",\n \"statusStaleRawModels\": \"Claude Desktop profile 中存在非 Sonnet / Opus / Haiku 角色模型名稱,新版 Claude Desktop 可能拒絕載入;重新切換目前供應商可修復。\",\n \"statusMissingRouteMappings\": \"目前供應商啟用了模型對應,但沒有有效路由;請編輯供應商並補全至少一個模型對應。\",\n \"statusGatewayTokenMissing\": \"目前本地路由 token 尚未產生;重新切換該供應商會寫入新的本地 token。\",\n \"statusBaseUrlMismatch\": \"Claude Desktop profile 指向的位址與目前供應商不一致;目前為 {{actual}},應為 {{expected}}。重新切換目前供應商可修復。\",\n \"route\": {\n \"tooltip\": {\n \"active\": \"Claude Desktop 本地路由已開啟 - {{address}}:{{port}}\",\n \"inactive\": \"開啟 Claude Desktop 本地路由,用於需要模型對應或格式轉換的供應商。目前設定位址:{{address}}:{{port}}\"\n }\n }\n },\n \"notifications\": {\n \"providerAdded\": \"供應商已新增\",\n \"providerSaved\": \"供應商設定已儲存\",\n \"providerDeleted\": \"供應商刪除成功\",\n \"switchSuccess\": \"切換成功!\",\n \"codexRestartRequired\": \"切換成功,請重新啟動客戶端以套用\",\n \"claudeDesktopRestartRequired\": \"切換成功,重新啟動 Claude Desktop 後生效\",\n \"claudeDesktopProxyRestartRequired\": \"切換成功,請保持 CC Switch 執行,並重新啟動 Claude Desktop 後生效\",\n \"addToConfigSuccess\": \"已新增至設定\",\n \"removeFromConfigSuccess\": \"已從設定移除\",\n \"switchFailedTitle\": \"切換失敗\",\n \"switchFailed\": \"切換失敗:{{error}}\",\n \"autoImported\": \"已從現有設定建立預設供應商\",\n \"addFailed\": \"新增供應商失敗:{{error}}\",\n \"saveFailed\": \"儲存失敗:{{error}}\",\n \"saveFailedGeneric\": \"儲存失敗,請重試\",\n \"appliedToClaudePlugin\": \"已套用至 Claude 外掛程式\",\n \"removedFromClaudePlugin\": \"已從 Claude 外掛程式移除\",\n \"syncClaudePluginFailed\": \"同步 Claude 外掛程式失敗\",\n \"skipClaudeOnboardingFailed\": \"跳過 Claude Code 初次安裝確認失敗\",\n \"clearClaudeOnboardingSkipFailed\": \"還原 Claude Code 初次安裝確認失敗\",\n \"updateSuccess\": \"供應商更新成功\",\n \"updateFailed\": \"更新供應商失敗:{{error}}\",\n \"deleteSuccess\": \"供應商已刪除\",\n \"deleteFailed\": \"刪除供應商失敗:{{error}}\",\n \"settingsSaved\": \"設定已儲存\",\n \"settingsSaveFailed\": \"儲存設定失敗:{{error}}\",\n \"proxyRequiredForSwitch\": \"此供應商 {{reason}},需要路由服務才能正常使用,請先啟動路由\",\n \"proxyReasonCopilot\": \"使用 GitHub Copilot 作為 Claude 供應商\",\n \"proxyReasonOpenAIChat\": \"使用 OpenAI Chat API 格式\",\n \"proxyReasonOpenAIResponses\": \"使用 OpenAI Responses API 格式\",\n \"proxyReasonFullUrl\": \"開啟了完整 URL 連線模式\",\n \"openAIFormatHint\": \"此供應商使用 OpenAI 相容格式,需要開啟路由服務才能正常使用\",\n \"copilotProxyHint\": \"GitHub Copilot 作為 Claude 供應商時始終需要本地路由;路由會根據目前模型自動選擇 Chat Completions 或 Responses。\",\n \"openLinkFailed\": \"連結開啟失敗\",\n \"openclawModelsRegistered\": \"模型已註冊至 /model 清單\",\n \"openclawDefaultModelSet\": \"已設為預設模型\",\n \"openclawDefaultModelSetFailed\": \"設定預設模型失敗\",\n \"openclawNoModels\": \"該供應商沒有設定模型\",\n \"backfillWarning\": \"切換成功,但舊供應商設定回填失敗,您手動修改的設定可能未儲存\",\n \"windowControlFailed\": \"視窗控制失敗:{{error}}\",\n \"officialBlockedByProxy\": \"本地路由模式下不能切換至官方供應商,使用路由存取官方 API 可能導致帳號被封鎖\",\n \"proxyOfficialWarning\": \"目前供應商 {{name}} 是官方供應商,建議切換至第三方供應商後再使用本地路由\"\n },\n \"confirm\": {\n \"deleteProvider\": \"刪除供應商\",\n \"deleteProviderMessage\": \"確定要刪除供應商「{{name}}」嗎?此操作無法復原。\",\n \"removeProvider\": \"移除供應商\",\n \"removeProviderMessage\": \"確定要從設定中移除供應商「{{name}}」嗎?\\n\\n移除後該供應商將不再生效,但設定資料會保留在 CC Switch 中,您可以隨時重新新增。\",\n \"proxy\": {\n \"title\": \"啟用本地路由服務\",\n \"message\": \"本地路由是一項進階功能,啟用前請確保您已了解其運作原理。\\n\\n建議先查閱相關文件或諮詢您的供應商,以取得正確的設定方式。\",\n \"confirm\": \"我已了解,繼續啟用\"\n },\n \"failover\": {\n \"title\": \"啟用故障轉移功能\",\n \"message\": \"故障轉移是一項進階功能,啟用前請確保您已了解其運作原理。\\n\\n建議先在故障轉移佇列中設定好供應商優先順序。\",\n \"confirm\": \"我已了解,繼續啟用\"\n },\n \"unifyCodexHistory\": {\n \"title\": \"統一 Codex 會話歷史\",\n \"message\": \"開啟後,官方訂閱與第三方將共用同一個會話歷史清單。注意:跨供應商繼續舊會話時,可能因對方後端無法解密 encrypted_content 推理內容而失敗。\\n\\n可選擇同時把現有官方會話歷史遷入共享清單(遷移前自動備份到 ~/.cc-switch/backups,關閉開關時可選擇恢復)。\",\n \"migrateExisting\": \"同時遷入現有官方會話歷史\",\n \"confirm\": \"我已了解,繼續開啟\"\n },\n \"unifyCodexHistoryOff\": {\n \"title\": \"關閉統一會話歷史\",\n \"message\": \"關閉後,官方訂閱與第三方將恢復各自獨立的會話歷史清單。開啟期間產生的會話因無法區分來源,將留在第三方歷史中,官方訂閱將看不到它們。\",\n \"restoreBackup\": \"把開啟時遷入的官方會話還原回官方歷史(按備份精確還原)\",\n \"confirm\": \"關閉\"\n },\n \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"autoSync\": {\n \"title\": \"開啟自動同步\",\n \"message\": \"開啟自動同步後,每次資料庫變更都會自動上傳至 WebDAV 伺服器。\\n\\n這可能會產生較高的網路流量消耗,請確保您的網路環境和 WebDAV 服務支援頻繁的資料傳輸。\",\n \"confirm\": \"我已了解,繼續開啟\"\n },\n \"commonConfig\": {\n \"title\": \"關於通用設定\",\n \"message\": \"「通用設定片段」可以在不同供應商之間共用外掛程式、環境變數等設定,避免切換供應商時遺失這些設定。\\n\\n使用方法:\\n① 編輯供應商時點擊「編輯通用設定」→「從編輯內容擷取」\\n② 新增供應商時勾選「套用通用設定」(預設已勾選)\\n\\n如果您新安裝了外掛程式或 Hook,請重新擷取一次通用設定。\",\n \"confirm\": \"我了解了\"\n }\n },\n \"settings\": {\n \"title\": \"設定\",\n \"general\": \"通用\",\n \"tabGeneral\": \"通用\",\n \"tabAuth\": \"驗證\",\n \"tabAdvanced\": \"進階\",\n \"tabProxy\": \"路由\",\n \"authCenter\": {\n \"title\": \"OAuth 驗證中心\",\n \"description\": \"在 Claude Code 中使用您的其他訂閱,請注意合規風險。\",\n \"beta\": \"Beta\",\n \"copilotDescription\": \"管理 GitHub Copilot 帳號\",\n \"codexOauthDescription\": \"管理 ChatGPT 帳號\"\n },\n \"advanced\": {\n \"configDir\": {\n \"title\": \"設定檔目錄\",\n \"description\": \"管理 Claude、Codex 和 Gemini 的設定儲存路徑\"\n },\n \"proxy\": {\n \"title\": \"本地路由\",\n \"description\": \"控制路由服務開關、查看狀態與連接埠資訊\",\n \"enableFeature\": \"在主頁面顯示本地路由開關\",\n \"enableFeatureDescription\": \"開啟後,主頁面頂部將顯示路由和故障轉移開關\",\n \"enableFailoverToggle\": \"在主頁面顯示故障轉移開關\",\n \"enableFailoverToggleDescription\": \"開啟後,主頁面頂部將獨立顯示故障轉移開關\",\n \"running\": \"執行中\",\n \"stopped\": \"已停止\"\n },\n \"connectivityCheck\": {\n \"title\": \"連通檢測設定\",\n \"description\": \"設定供應商位址探測的逾時、重試與緩慢閾值\"\n },\n \"failover\": {\n \"title\": \"自動故障轉移\",\n \"description\": \"設定故障轉移佇列和斷路器策略\"\n },\n \"pricing\": {\n \"title\": \"成本定價\",\n \"description\": \"管理各模型 Token 計費規則\"\n },\n \"globalProxy\": {\n \"title\": \"全域出站代理伺服器\",\n \"description\": \"設定 CC Switch 存取外部 API 時使用的代理伺服器\"\n },\n \"data\": {\n \"title\": \"資料管理\",\n \"description\": \"匯入和匯出本地設定資料\"\n },\n \"backup\": {\n \"title\": \"備份與還原\",\n \"description\": \"管理自動備份,查看和還原資料庫快照\"\n },\n \"cloudSync\": {\n \"title\": \"雲端同步\",\n \"description\": \"透過 WebDAV 在多裝置間同步資料\"\n },\n \"rectifier\": {\n \"title\": \"整流器\",\n \"description\": \"自動修復 API 請求中的相容性問題\",\n \"enabled\": \"啟用整流器\",\n \"enabledDescription\": \"總開關,關閉後所有整流功能將被停用\",\n \"requestGroup\": \"請求整流\",\n \"responseGroup\": \"回應整流\",\n \"thinkingSignature\": \"Thinking 簽章整流\",\n \"thinkingSignatureDescription\": \"當 Anthropic 類型供應商回傳 thinking 簽章不相容或非法請求等錯誤時,自動移除不相容的 thinking 相關區塊,並對同一供應商重試一次\",\n \"thinkingBudget\": \"Thinking Budget 整流\",\n \"thinkingBudgetDescription\": \"當 Anthropic 類型供應商回傳 budget_tokens 限制錯誤(如至少 1024)時,自動將 thinking 規範為 enabled,並將 budget 設為 32000,同時在需要時將 max_tokens 設為 64000,然後重試一次\",\n \"mediaFallback\": \"不支援圖片降級\",\n \"mediaFallbackDescription\": \"當供應商不支援圖片輸入時(已宣告純文字能力,或上游回傳不支援圖片的錯誤),自動將圖片區塊替換為 [Unsupported Image] 標記,使對話不中斷\",\n \"mediaHeuristic\": \"純文字模型預判\",\n \"mediaHeuristicDescription\": \"傳送前依內建的已確認純文字模型登錄表移除圖片。關閉只會停用代理的登錄表預判;已宣告純文字模型的處理與上游錯誤兜底仍會保留,且不會改變 Codex 模型目錄的能力宣告\"\n },\n \"optimizer\": {\n \"title\": \"Bedrock 請求最佳化工具\",\n \"description\": \"在請求發送前自動最佳化 Thinking 和 Cache 設定(僅 Bedrock 供應商生效)\",\n \"enabled\": \"啟用最佳化工具\",\n \"thinkingOptimizer\": \"Thinking 最佳化\",\n \"thinkingOptimizerDescription\": \"自動為 Opus/Sonnet 啟用 Adaptive Thinking,為舊模型注入 Extended Thinking\",\n \"cacheInjection\": \"Cache 注入\",\n \"cacheInjectionDescription\": \"自動在請求關鍵位置注入標準 5 分鐘 Cache 斷點,減少重複 token 計費\"\n },\n \"logConfig\": {\n \"title\": \"日誌管理\",\n \"description\": \"控制日誌輸出層級\",\n \"enabled\": \"啟用日誌\",\n \"enabledDescription\": \"總開關,關閉後所有日誌將被停用\",\n \"level\": \"日誌層級\",\n \"levelDescription\": \"設定輸出的最低日誌層級\",\n \"levels\": {\n \"error\": \"錯誤\",\n \"warn\": \"警告\",\n \"info\": \"資訊\",\n \"debug\": \"偵錯\",\n \"trace\": \"追蹤\"\n },\n \"levelHint\": \"日誌層級說明:\",\n \"levelDesc\": {\n \"error\": \"僅嚴重錯誤\",\n \"warn\": \"錯誤 + 警告資訊\",\n \"info\": \"一般操作資訊(預設)\",\n \"debug\": \"詳細資訊,包含 SSE 串流與請求/回應詳情\",\n \"trace\": \"全部日誌,最詳細\"\n }\n }\n },\n \"language\": \"介面語言\",\n \"languageHint\": \"切換後立即預覽介面語言,儲存後永久生效。\",\n \"theme\": \"外觀主題\",\n \"themeHint\": \"選擇應用程式的外觀主題,立即生效。\",\n \"themeLight\": \"淺色\",\n \"themeDark\": \"深色\",\n \"themeSystem\": \"跟隨系統\",\n \"importExport\": \"SQL 匯入匯出\",\n \"importExportHint\": \"匯入/匯出資料庫 SQL 備份(僅支援匯入由 CC Switch 匯出的備份),便於備份或遷移。\",\n \"exportConfig\": \"匯出 SQL 備份\",\n \"selectConfigFile\": \"選擇 SQL 檔案\",\n \"noFileSelected\": \"尚未選擇設定檔。\",\n \"import\": \"匯入\",\n \"importing\": \"匯入中...\",\n \"importSuccess\": \"匯入成功!\",\n \"importFailed\": \"匯入失敗\",\n \"syncLiveFailed\": \"已匯入,但同步至目前供應商失敗,請手動重新選擇一次供應商。\",\n \"importPartialSuccess\": \"設定已匯入,但同步至目前供應商失敗。\",\n \"importPartialHint\": \"請手動重新選擇一次供應商以重新整理對應設定。\",\n \"configExported\": \"設定已匯出至:\",\n \"exportFailed\": \"匯出失敗\",\n \"selectFileFailed\": \"請選擇有效的 SQL 備份檔案\",\n \"configCorrupted\": \"SQL 檔案可能已損壞或格式不正確\",\n \"backupId\": \"備份 ID\",\n \"backupManager\": {\n \"title\": \"資料庫備份\",\n \"description\": \"自動備份的資料庫快照,可用於還原至之前的狀態\",\n \"empty\": \"暫無備份\",\n \"restore\": \"還原\",\n \"restoring\": \"還原中...\",\n \"confirmTitle\": \"確認還原備份\",\n \"confirmMessage\": \"還原至此備份將覆寫目前資料庫。還原前會自動建立安全備份。\",\n \"restoreSuccess\": \"還原成功!安全備份已建立\",\n \"restoreFailed\": \"還原失敗\",\n \"safetyBackupId\": \"安全備份 ID\",\n \"intervalLabel\": \"自動備份間隔\",\n \"retainLabel\": \"備份保留數量\",\n \"intervalDisabled\": \"停用\",\n \"intervalHours\": \"{{hours}} 小時\",\n \"intervalDays\": \"{{days}} 天\",\n \"rename\": \"重新命名\",\n \"renameSuccess\": \"備份重新命名成功\",\n \"renameFailed\": \"重新命名失敗\",\n \"namePlaceholder\": \"輸入新名稱\",\n \"createBackup\": \"立即備份\",\n \"creating\": \"備份中...\",\n \"createSuccess\": \"備份建立成功\",\n \"createFailed\": \"備份建立失敗\",\n \"delete\": \"刪除\",\n \"deleting\": \"刪除中...\",\n \"deleteSuccess\": \"備份已刪除\",\n \"deleteFailed\": \"刪除失敗\",\n \"deleteConfirmTitle\": \"確認刪除備份\",\n \"deleteConfirmMessage\": \"此備份將被永久刪除,此操作無法復原。\"\n },\n \"webdavSync\": {\n \"title\": \"WebDAV 雲端同步\",\n \"description\": \"透過 WebDAV 在多裝置間同步資料庫和技能設定。\",\n \"baseUrl\": \"WebDAV 伺服器位址\",\n \"baseUrlPlaceholder\": \"https://dav.jianguoyun.com/dav/\",\n \"username\": \"WebDAV 帳號\",\n \"usernamePlaceholder\": \"信箱帳號\",\n \"password\": \"WebDAV 密碼\",\n \"passwordPlaceholder\": \"應用程式密碼(堅果雲請使用「第三方應用程式密碼」)\",\n \"remoteRoot\": \"遠端根目錄\",\n \"profile\": \"同步設定名稱\",\n \"autoSync\": \"自動同步\",\n \"autoSyncHint\": \"開啟後每次資料庫變更都會自動上傳至 WebDAV。\",\n \"test\": \"測試連線\",\n \"testing\": \"測試中...\",\n \"testSuccess\": \"連線成功\",\n \"testFailed\": \"連線失敗:{{error}}\",\n \"save\": \"儲存設定\",\n \"saving\": \"儲存中...\",\n \"saveFailed\": \"儲存設定失敗:{{error}}\",\n \"upload\": \"上傳至雲端\",\n \"uploading\": \"上傳中...\",\n \"uploadSuccess\": \"已上傳至 WebDAV\",\n \"uploadFailed\": \"上傳失敗:{{error}}\",\n \"autoSyncFailedToast\": \"自動同步失敗:{{error}}\",\n \"download\": \"從雲端下載\",\n \"downloading\": \"下載中...\",\n \"downloadSuccess\": \"已從 WebDAV 下載並還原\",\n \"downloadFailed\": \"下載失敗:{{error}}\",\n \"lastSync\": \"上次同步:{{time}}\",\n \"autoSyncLastErrorTitle\": \"上次自動同步失敗\",\n \"autoSyncLastErrorHint\": \"請檢查網路或 WebDAV 設定,系統會在後續變更時繼續自動重試。\",\n \"missingUrl\": \"請填寫 WebDAV 伺服器位址\",\n \"presets\": {\n \"label\": \"服務商\",\n \"jianguoyun\": \"堅果雲\",\n \"jianguoyunHint\": \"請在堅果雲「安全選項」中產生「第三方應用程式密碼」,不要使用登入密碼。\",\n \"nextcloud\": \"Nextcloud\",\n \"nextcloudHint\": \"請將 your-server 替換為您的 Nextcloud 伺服器位址,USERNAME 替換為您的使用者名稱。\",\n \"synology\": \"Synology NAS\",\n \"synologyHint\": \"請先在 Synology「套件中心」安裝並啟用 WebDAV Server 套件。\",\n \"custom\": \"自訂\"\n },\n \"remoteRootDefault\": \"預設: cc-switch-sync\",\n \"profileDefault\": \"預設: default\",\n \"saveAndTestSuccess\": \"設定已儲存,連線正常\",\n \"saveAndTestFailed\": \"設定已儲存,但連線測試失敗:{{error}}\",\n \"noRemoteData\": \"雲端沒有找到同步資料\",\n \"incompatibleVersion\": \"遠端資料版本不相容(協定 v{{protocolVersion}},資料庫 {{dbCompatVersion}}),目前支援協定 v2 / db-v6\",\n \"unsaved\": \"未儲存\",\n \"saved\": \"已儲存\",\n \"unsavedChanges\": \"請先儲存設定\",\n \"saveBeforeSync\": \"請先儲存設定,再使用上傳/下載。\",\n \"fetchingRemote\": \"取得遠端資訊...\",\n \"fetchRemoteFailed\": \"取得遠端資訊失敗,請檢查設定和網路後重試。\",\n \"confirmDownload\": {\n \"title\": \"從雲端還原\",\n \"deviceName\": \"上傳裝置\",\n \"createdAt\": \"上傳時間\",\n \"path\": \"遠端路徑\",\n \"dbCompat\": \"資料庫相容層\",\n \"artifacts\": \"包含內容\",\n \"legacyNotice\": \"檢測到舊版雲端路徑。還原完成後,下次上傳將寫入新路徑 v2/db-v6。\",\n \"warning\": \"還原將覆寫本地所有資料和技能設定\",\n \"confirm\": \"確認還原\"\n },\n \"confirmUpload\": {\n \"title\": \"上傳至雲端\",\n \"content\": \"將同步以下內容至 WebDAV 伺服器:\",\n \"dbItem\": \"資料庫(所有 Provider 設定和資料)\",\n \"skillsItem\": \"技能包(所有自訂技能)\",\n \"targetPath\": \"目標路徑\",\n \"existingData\": \"雲端已有資料\",\n \"deviceName\": \"上傳裝置\",\n \"createdAt\": \"上傳時間\",\n \"path\": \"遠端路徑\",\n \"dbCompat\": \"資料庫相容層\",\n \"warning\": \"將覆寫雲端已有的同步資料\",\n \"legacyNotice\": \"檢測到舊版雲端路徑資料。本次上傳將寫入新路徑 v2/db-v6,不會覆寫舊路徑。\",\n \"confirm\": \"確認上傳\"\n }\n },\n \"syncType\": {\n \"label\": \"同步方式\",\n \"webdav\": \"WebDAV\",\n \"s3\": \"S3 相容儲存\"\n },\n \"s3Sync\": {\n \"presets\": {\n \"label\": \"服務商\",\n \"awsS3\": \"AWS S3\",\n \"awsS3Hint\": \"使用 IAM Access Key,需要 s3:PutObject/GetObject/HeadObject 權限。Region 範例:us-east-1, ap-northeast-1\",\n \"minio\": \"MinIO\",\n \"minioHint\": \"需要自訂 Endpoint,如 http://minio.local:9000。Region 可設為 us-east-1\",\n \"r2\": \"Cloudflare R2\",\n \"r2Hint\": \"Endpoint: https://<account_id>.r2.cloudflarestorage.com,Region 設為 auto\",\n \"oss\": \"阿里雲 OSS\",\n \"ossHint\": \"Endpoint: https://oss-<region>.aliyuncs.com,Region 範例:cn-hangzhou, cn-shanghai\",\n \"cos\": \"騰訊雲 COS\",\n \"cosHint\": \"Endpoint: https://cos.<region>.myqcloud.com,Region 範例:ap-guangzhou, ap-beijing\",\n \"obs\": \"華為雲 OBS\",\n \"obsHint\": \"Endpoint: https://obs.<region>.myhuaweicloud.com,Region 範例:cn-north-4, cn-east-3\",\n \"custom\": \"S3 相容服務(自訂)\",\n \"customHint\": \"任意 S3 相容服務,需要自訂 Endpoint\"\n },\n \"region\": \"區域 (Region)\",\n \"regionPlaceholder\": \"例如 us-east-1\",\n \"bucket\": \"儲存桶 (Bucket)\",\n \"bucketPlaceholder\": \"例如 my-cc-switch-bucket\",\n \"accessKeyId\": \"Access Key ID\",\n \"accessKeyIdPlaceholder\": \"例如 AKIA...\",\n \"secretAccessKey\": \"Secret Access Key\",\n \"secretAccessKeyPlaceholder\": \"留空則保持現有金鑰\",\n \"endpoint\": \"Endpoint(AWS 可留空)\",\n \"endpointHint\": \"多數 S3 相容服務需要填寫\",\n \"endpointPlaceholder\": \"例如 https://s3-compatible.example.com\",\n \"remoteRoot\": \"遠端根目錄\",\n \"remoteRootDefault\": \"預設: cc-switch-sync\",\n \"profile\": \"同步設定檔名稱\",\n \"profileDefault\": \"預設: default\",\n \"autoSync\": \"自動同步\",\n \"autoSyncHint\": \"開啟後每次資料庫變更都會自動上傳到 S3。\",\n \"enabled\": \"啟用 S3 同步\",\n \"enabledHint\": \"開啟後可使用 S3 上傳/下載和自動同步。\",\n \"mutualExclusionTitle\": \"切換同步方式\",\n \"mutualExclusionMessage\": \"啟用 S3 同步將停用目前的 WebDAV 同步,是否繼續?\",\n \"mutualExclusionMessageReverse\": \"啟用 WebDAV 同步將停用目前的 S3 同步,是否繼續?\",\n \"mutualExclusionFailed\": \"切換同步方式失敗:{{error}}\",\n \"testConnection\": \"測試連線\",\n \"connectionOk\": \"S3 連線成功\",\n \"test\": \"測試連線\",\n \"testing\": \"測試中...\",\n \"testSuccess\": \"S3 連線成功\",\n \"testFailed\": \"S3 連線失敗:{{error}}\",\n \"save\": \"儲存設定\",\n \"saving\": \"儲存中...\",\n \"saveFailed\": \"儲存設定失敗:{{error}}\",\n \"saveAndTestSuccess\": \"設定已儲存,S3 連線正常\",\n \"saveAndTestFailed\": \"設定已儲存,但 S3 連線測試失敗:{{error}}\",\n \"upload\": \"上傳至雲端\",\n \"uploading\": \"上傳中...\",\n \"uploadSuccess\": \"已上傳到 S3\",\n \"uploadFailed\": \"S3 上傳失敗:{{error}}\",\n \"autoSyncFailedToast\": \"S3 自動同步失敗:{{error}}\",\n \"download\": \"從雲端下載\",\n \"downloading\": \"下載中...\",\n \"downloadSuccess\": \"已從 S3 下載並還原\",\n \"downloadFailed\": \"S3 下載失敗:{{error}}\",\n \"lastSync\": \"上次同步:{{time}}\",\n \"autoSyncLastErrorTitle\": \"上次 S3 自動同步失敗\",\n \"autoSyncLastErrorHint\": \"請檢查網路或 S3 設定,系統會在後續變更時繼續自動重試。\",\n \"missingBucket\": \"請填寫 S3 儲存桶\",\n \"noRemoteData\": \"S3 儲存桶中沒有找到同步資料\",\n \"incompatibleVersion\": \"遠端資料版本不相容(版本 {{version}}),目前支援協議 v2 / db-v6\",\n \"unsaved\": \"未儲存\",\n \"saved\": \"已儲存\",\n \"unsavedChanges\": \"請先儲存設定\",\n \"saveBeforeSync\": \"請先儲存設定,再使用上傳/下載。\",\n \"fetchingRemote\": \"取得遠端資訊...\",\n \"fetchRemoteFailed\": \"取得遠端資訊失敗,請檢查設定和網路後重試。\",\n \"notConfigured\": \"未設定 S3 同步\",\n \"disabled\": \"S3 同步未啟用\",\n \"confirmDownload\": {\n \"title\": \"從 S3 還原\",\n \"deviceName\": \"上傳裝置\",\n \"createdAt\": \"上傳時間\",\n \"artifacts\": \"包含內容\",\n \"warning\": \"還原將覆寫本地所有資料和技能設定\",\n \"confirm\": \"確認還原\"\n },\n \"confirmUpload\": {\n \"title\": \"上傳到 S3\",\n \"content\": \"將同步以下內容到 S3 儲存桶:\",\n \"dbItem\": \"資料庫(所有 Provider 設定和資料)\",\n \"skillsItem\": \"技能包(所有自訂技能)\",\n \"targetPath\": \"目標物件路徑\",\n \"existingData\": \"雲端已有資料\",\n \"deviceName\": \"上傳裝置\",\n \"createdAt\": \"上傳時間\",\n \"warning\": \"將覆寫該 S3 路徑下已有的同步資料\",\n \"confirm\": \"確認上傳\"\n }\n },\n \"autoReload\": \"資料已重新整理\",\n \"languageOptionChinese\": \"简体中文\",\n \"languageOptionTraditionalChinese\": \"繁體中文\",\n \"languageOptionEnglish\": \"English\",\n \"languageOptionJapanese\": \"日本語\",\n \"windowBehavior\": \"視窗行為\",\n \"windowBehaviorHint\": \"設定視窗最小化與 Claude 外掛程式連動策略。\",\n \"launchOnStartup\": \"開機自動啟動\",\n \"launchOnStartupDescription\": \"隨系統啟動自動執行 CC Switch\",\n \"silentStartup\": \"靜默啟動\",\n \"silentStartupDescription\": \"程式啟動時不顯示主視窗,僅在系統匣執行\",\n \"autoLaunchFailed\": \"設定開機自動啟動失敗\",\n \"minimizeToTray\": \"關閉時最小化至系統匣\",\n \"minimizeToTrayDescription\": \"勾選後點擊關閉按鈕會隱藏至系統匣,取消則直接退出應用程式。\",\n \"useAppWindowControls\": \"啟用應用程式等級視窗按鈕\",\n \"useAppWindowControlsDescription\": \"開啟後使用應用程式自建的最小化、最大化/還原、關閉按鈕;關閉後沿用系統視窗模式。\",\n \"enableClaudePluginIntegration\": \"套用至 Claude Code 外掛程式\",\n \"enableClaudePluginIntegrationDescription\": \"開啟後 VS Code Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\n \"codexAuth\": \"Codex 應用增強\",\n \"preserveCodexOfficialAuthOnSwitch\": \"非接管切換時保留官方登入\",\n \"preserveCodexOfficialAuthOnSwitchDescription\": \"控制未開啟本機路由時切換第三方供應商是否保留 Codex 官方登入;路由接管期間一律保留\",\n \"unifyCodexSessionHistory\": \"統一 Codex 會話歷史\",\n \"unifyCodexSessionHistoryDescription\": \"開啟後,官方訂閱將以共享的 custom 供應商標識執行,官方與第三方會話出現在同一歷史清單中,並可選擇把現有官方會話一併遷入(遷移前自動備份)。關閉開關時可按備份恢復遷入的會話。注意:跨供應商繼續舊會話時,對方後端可能無法解密會話中的 encrypted_content 推理內容,導致繼續失敗\",\n \"unifyCodexHistoryRestoreCompleted\": \"已按備份還原官方會話歷史({{files}} 個會話檔案、{{rows}} 條索引記錄)\",\n \"unifyCodexHistoryRestoreFailed\": \"還原官方會話歷史失敗,請重試\",\n \"unifyCodexHistoryRestoreNothing\": \"目前 Codex 目錄沒有可恢復的遷移備份\",\n \"unifyCodexHistoryRestoreSkippedToggleOn\": \"統一會話歷史開關已重新開啟,已跳過還原\",\n \"appVisibility\": {\n \"title\": \"主頁面顯示\",\n \"description\": \"選擇在主頁面顯示的應用程式\",\n \"claudeDesc\": \"Anthropic Claude Code CLI\",\n \"codexDesc\": \"OpenAI Codex CLI\",\n \"geminiDesc\": \"Google Gemini CLI\",\n \"opencodeDesc\": \"OpenCode CLI\",\n \"showProfileSwitcher\": \"顯示專案切換\",\n \"showProfileSwitcherDescription\": \"在主頁面頂部顯示專案切換入口\"\n },\n \"skillStorage\": {\n \"title\": \"Skills 儲存位置\",\n \"description\": \"選擇 CC Switch 存放 Skills 主副本的目錄\",\n \"ccSwitch\": \"CC Switch\",\n \"unified\": \"~/.agents/skills\",\n \"ccSwitchHint\": \"技能儲存在 ~/.cc-switch/skills/,由 CC Switch 統一管理並同步至各應用程式。\",\n \"unifiedHint\": \"技能儲存在 ~/.agents/skills/,遵循 Agent Skills 開放標準。相容的工具(Claude Code、Codex、Gemini CLI 等)可直接發現此目錄中的技能。\",\n \"confirmTitle\": \"遷移技能儲存位置\",\n \"confirmMessage\": \"將移動 {{count}} 個技能至新位置,是否繼續?\",\n \"migrationSuccess\": \"已成功遷移 {{count}} 個技能\",\n \"migrationPartial\": \"遷移了 {{migrated}} 個技能,{{errors}} 個失敗,請查看日誌\"\n },\n \"skillSync\": {\n \"title\": \"Skills 同步方式\",\n \"description\": \"選擇 Skills 的檔案同步策略\",\n \"symlink\": \"軟連結\",\n \"copy\": \"檔案複製\",\n \"symlinkHint\": \"軟連結節省磁碟空間並支援即時同步。注意:Windows 可能需要管理員權限或開啟開發者模式\"\n },\n \"terminal\": {\n \"title\": \"首選終端機\",\n \"description\": \"選擇點擊終端機按鈕時使用的終端機應用程式\",\n \"fallbackHint\": \"如果選擇的終端機不可用,將自動使用系統預設終端機\",\n \"options\": {\n \"macos\": {\n \"terminal\": \"Terminal.app\",\n \"iterm2\": \"iTerm2\",\n \"alacritty\": \"Alacritty\",\n \"kitty\": \"Kitty\",\n \"ghostty\": \"Ghostty\",\n \"wezterm\": \"WezTerm\",\n \"kaku\": \"Kaku\",\n \"warp\": \"Warp\"\n },\n \"windows\": {\n \"cmd\": \"命令提示字元\",\n \"powershell\": \"PowerShell\",\n \"wt\": \"Windows Terminal\"\n },\n \"linux\": {\n \"gnomeTerminal\": \"GNOME Terminal\",\n \"konsole\": \"Konsole\",\n \"xfce4Terminal\": \"Xfce4 Terminal\",\n \"alacritty\": \"Alacritty\",\n \"kitty\": \"Kitty\",\n \"ghostty\": \"Ghostty\"\n }\n }\n },\n \"configDirectoryOverride\": \"設定目錄覆寫(進階)\",\n \"configDirectoryDescription\": \"在 WSL 等環境使用 Claude Code 或 Codex 的時候,可手動指定為 WSL 裡的設定目錄,供應商資料與主環境保持一致。\",\n \"appConfigDir\": \"CC Switch 設定目錄\",\n \"appConfigDirDescription\": \"自訂 CC Switch 的設定儲存位置(指定至雲端同步資料夾即可雲端同步設定)\",\n \"browsePlaceholderApp\": \"例如:C:\\\\Users\\\\Administrator\\\\.cc-switch\",\n \"claudeConfigDir\": \"Claude Code 設定目錄\",\n \"claudeConfigDirDescription\": \"覆寫 Claude 設定目錄 (settings.json)。真正自訂目錄會在目錄內存放 Claude MCP 的 .claude.json;預設 ~/.claude 目錄仍使用 Claude 預設的 ~/.claude.json。\",\n \"codexConfigDir\": \"Codex 設定目錄\",\n \"codexConfigDirDescription\": \"覆寫 Codex 設定目錄。\",\n \"geminiConfigDir\": \"Gemini 設定目錄\",\n \"geminiConfigDirDescription\": \"覆寫 Gemini 設定目錄 (.env)。\",\n \"opencodeConfigDir\": \"OpenCode 設定目錄\",\n \"opencodeConfigDirDescription\": \"覆寫 OpenCode 設定目錄 (opencode.json)。\",\n \"openclawConfigDir\": \"OpenClaw 設定目錄\",\n \"openclawConfigDirDescription\": \"覆寫 OpenClaw 設定目錄 (openclaw.json)。\",\n \"hermesConfigDir\": \"Hermes 設定目錄\",\n \"hermesConfigDirDescription\": \"覆寫 Hermes 設定目錄 (config.yaml)。\",\n \"browsePlaceholderClaude\": \"例如:/home/<您的帳號>/.claude\",\n \"browsePlaceholderCodex\": \"例如:/home/<您的帳號>/.codex\",\n \"browsePlaceholderGemini\": \"例如:/home/<您的帳號>/.gemini\",\n \"browsePlaceholderOpencode\": \"例如:/home/<您的帳號>/.config/opencode\",\n \"browsePlaceholderOpenclaw\": \"例如:/home/<您的帳號>/.openclaw\",\n \"browsePlaceholderHermes\": \"例如:/home/<您的帳號>/.hermes\",\n \"browseDirectory\": \"瀏覽目錄\",\n \"resetDefault\": \"還原預設目錄(需儲存後生效)\",\n \"checkForUpdates\": \"檢查更新\",\n \"updateTo\": \"更新至 v{{version}}\",\n \"updating\": \"更新中...\",\n \"checking\": \"檢查中...\",\n \"upToDate\": \"已是最新\",\n \"aboutHint\": \"檢視版本資訊與更新狀態。\",\n \"portableMode\": \"目前為可攜版,更新需手動下載。\",\n \"updateAvailable\": \"檢測到新版本:{{version}}\",\n \"updateBadge\": \"有更新可用\",\n \"updateFailed\": \"更新安裝失敗,已嘗試開啟下載頁面。\",\n \"checkUpdateFailed\": \"檢查更新失敗,請稍後重試。\",\n \"openReleaseNotesFailed\": \"開啟更新日誌失敗\",\n \"releaseNotes\": \"更新日誌\",\n \"viewReleaseNotes\": \"檢視該版本更新日誌\",\n \"viewCurrentReleaseNotes\": \"檢視目前版本更新日誌\",\n \"officialWebsite\": \"官方網站\",\n \"github\": \"GitHub\",\n \"oneClickInstall\": \"一鍵安裝\",\n \"oneClickInstallHint\": \"安裝 Claude Code / Codex / Gemini CLI / OpenCode\",\n \"localEnvCheck\": \"本地環境檢查\",\n \"envBadge\": {\n \"wsl\": \"WSL\",\n \"windows\": \"Win\",\n \"macos\": \"macOS\",\n \"linux\": \"Linux\"\n },\n \"wslShell\": \"Shell\",\n \"wslShellFlag\": \"標誌\",\n \"installCommandsCopied\": \"安裝命令已複製\",\n \"installCommandsCopyFailed\": \"複製失敗,請手動複製。\",\n \"importFailedError\": \"匯入設定失敗:{{message}}\",\n \"exportFailedError\": \"匯出設定失敗:\",\n \"restartRequired\": \"需要重新啟動應用程式\",\n \"restartRequiredMessage\": \"修改 CC Switch 設定目錄後需要重新啟動應用程式才能生效,是否立即重新啟動?\",\n \"restartNow\": \"立即重新啟動\",\n \"restartLater\": \"稍後重新啟動\",\n \"restartFailed\": \"應用程式重新啟動失敗,請手動關閉後重新開啟。\",\n \"devModeRestartHint\": \"開發模式下不支援自動重新啟動,請手動重新啟動應用程式。\",\n \"saving\": \"正在儲存...\",\n \"globalProxy\": {\n \"label\": \"全域代理伺服器\",\n \"hint\": \"代理所有請求(API、Skills 下載等)。開啟本地路由時,應用程式的請求也會經過此代理伺服器。留空表示直連。\",\n \"username\": \"使用者名稱(選填)\",\n \"password\": \"密碼(選填)\",\n \"test\": \"測試連線\",\n \"scan\": \"掃描本地代理伺服器\",\n \"clear\": \"清除\",\n \"scanFailed\": \"掃描失敗:{{error}}\",\n \"saved\": \"代理伺服器設定已儲存\",\n \"saveFailed\": \"儲存失敗:{{error}}\",\n \"testSuccess\": \"連線成功!延遲 {{latency}}ms\",\n \"testFailed\": \"連線失敗:{{error}}\",\n \"pricingDefaultsTitle\": \"計費預設設定\",\n \"pricingDefaultsDescription\": \"設定各應用程式的預設倍率與計費模式來源。\",\n \"pricingAppLabel\": \"應用程式\",\n \"defaultCostMultiplierLabel\": \"預設倍率\",\n \"defaultCostMultiplierHint\": \"用於成本計算的倍率,支援小數。\",\n \"pricingModelSourceLabel\": \"計費模式\",\n \"pricingModelSourceRequest\": \"請求模型\",\n \"pricingModelSourceResponse\": \"回傳模型\",\n \"pricingSave\": \"儲存計費設定\",\n \"pricingSaved\": \"計費設定已儲存\",\n \"pricingSaveFailed\": \"儲存計費設定失敗:{{error}}\",\n \"pricingLoadFailed\": \"載入計費設定失敗:{{error}}\",\n \"defaultCostMultiplierRequired\": \"預設倍率不能為空\",\n \"defaultCostMultiplierInvalid\": \"預設倍率格式不正確\"\n },\n \"saveFailedGeneric\": \"儲存失敗,請重試\"\n },\n \"apps\": {\n \"claude\": \"Claude\",\n \"claudeCode\": \"Claude Code\",\n \"claudeDesktop\": \"Claude Desktop\",\n \"claude-desktop\": \"Claude Desktop\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\",\n \"openclaw\": \"OpenClaw\",\n \"hermes\": \"Hermes\",\n \"grok\": \"Grok Build\"\n },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"providerFilterTooltip\": \"供應商篩選\",\n \"sessionList\": \"工作階段清單\",\n \"viewModeTooltip\": \"檢視方式\",\n \"viewModeFlat\": \"清單\",\n \"viewModeGrouped\": \"分類\",\n \"unknownDirectory\": \"未知目錄\",\n \"toggleProviderGroup\": \"展開或摺疊 {{provider}} 供應商分組\",\n \"toggleDirectoryGroup\": \"展開或摺疊 {{directory}} 目錄分組\",\n \"collapseAllGroups\": \"全部收起\",\n \"selectProviderGroupForBatch\": \"選擇 {{provider}} 供應商分組內工作階段\",\n \"selectDirectoryGroupForBatch\": \"選擇 {{directory}} 目錄分組內工作階段\",\n \"manageBatchTooltip\": \"進入批次管理\",\n \"exitBatchModeTooltip\": \"退出批次管理\",\n \"batchModeHint\": \"勾選要刪除的工作階段\",\n \"selectForBatch\": \"選擇工作階段\",\n \"selectedCount\": \"已選 {{count}} 項\",\n \"selectAllFiltered\": \"全選目前\",\n \"clearFilteredSelection\": \"取消全選\",\n \"clearSelection\": \"清空已選\",\n \"deleteSelected\": \"批次刪除\",\n \"batchDeleting\": \"批次刪除中...\",\n \"loadingSessions\": \"載入工作階段中...\",\n \"noSessions\": \"未發現工作階段\",\n \"selectSession\": \"請選擇工作階段檢視詳情\",\n \"noSummary\": \"暫無摘要\",\n \"lastActive\": \"最近活躍\",\n \"projectDir\": \"專案目錄\",\n \"sourcePath\": \"原始檔案\",\n \"copyResumeCommand\": \"複製繼續命令\",\n \"resumeCommandCopied\": \"已複製繼續命令\",\n \"openInTerminal\": \"在終端機繼續\",\n \"terminalTargetTerminal\": \"Terminal\",\n \"terminalTargetKitty\": \"kitty\",\n \"terminalTargetCopy\": \"僅複製\",\n \"terminalLaunched\": \"終端機已啟動\",\n \"openFailed\": \"開啟終端機失敗\",\n \"resumeFallbackCopied\": \"已複製繼續命令,可手動貼上至終端機\",\n \"copyProjectDir\": \"複製目錄\",\n \"projectDirCopied\": \"目錄已複製\",\n \"copySourcePath\": \"複製原始檔案\",\n \"sourcePathCopied\": \"原始檔案已複製\",\n \"delete\": \"刪除工作階段\",\n \"deleting\": \"刪除中...\",\n \"deleteTooltip\": \"永久刪除此本地工作階段紀錄\",\n \"deleteConfirmTitle\": \"刪除工作階段\",\n \"deleteConfirmMessage\": \"將永久刪除本地工作階段「{{title}}」\\nSession ID: {{sessionId}}\\n\\n此操作不可復原。\",\n \"deleteConfirmAction\": \"刪除工作階段\",\n \"sessionDeleted\": \"工作階段已刪除\",\n \"deleteFailed\": \"刪除工作階段失敗:{{error}}\",\n \"batchDeleteConfirmTitle\": \"批次刪除工作階段\",\n \"batchDeleteConfirmMessage\": \"將永久刪除已選取的 {{count}} 個本地工作階段紀錄。\\n\\n此操作不可復原。\",\n \"batchDeleteConfirmAction\": \"刪除所選工作階段\",\n \"batchDeleteSuccess\": \"已刪除 {{count}} 個工作階段\",\n \"batchDeleteFailed\": \"{{failed}} 個工作階段刪除失敗\",\n \"batchDeleteRequestFailed\": \"批次刪除失敗,請稍後重試\",\n \"loadingMessages\": \"載入對話內容中...\",\n \"emptySession\": \"該工作階段暫無可展示內容\",\n \"clickToCopyPath\": \"點擊複製路徑\",\n \"tocTitle\": \"對話目錄\",\n \"justNow\": \"剛剛\",\n \"minutesAgo\": \"{{count}} 分鐘前\",\n \"hoursAgo\": \"{{count}} 小時前\",\n \"daysAgo\": \"{{count}} 天前\",\n \"roleUser\": \"使用者\",\n \"roleSystem\": \"系統\",\n \"roleTool\": \"工具\",\n \"resume\": \"繼續工作階段\",\n \"resumeTooltip\": \"在終端機中繼續此工作階段\",\n \"noResumeCommand\": \"此工作階段無法繼續\",\n \"copyCommand\": \"複製命令\",\n \"copyMessage\": \"複製訊息\",\n \"messageCopied\": \"已複製訊息內容\",\n \"conversationHistory\": \"對話紀錄\",\n \"expandContent\": \"展開完整內容\",\n \"collapseContent\": \"收起\"\n },\n \"console\": {\n \"providerSwitchReceived\": \"收到供應商切換事件:\",\n \"setupListenerFailed\": \"設定供應商切換監聽器失敗:\",\n \"updateProviderFailed\": \"更新供應商失敗:\",\n \"autoImportFailed\": \"自動匯入預設設定失敗:\",\n \"openLinkFailed\": \"開啟連結失敗:\",\n \"getVersionFailed\": \"取得版本資訊失敗:\",\n \"loadSettingsFailed\": \"載入設定失敗:\",\n \"getConfigPathFailed\": \"取得設定路徑失敗:\",\n \"getConfigDirFailed\": \"取得設定目錄失敗:\",\n \"detectPortableFailed\": \"檢測可攜模式失敗:\",\n \"saveSettingsFailed\": \"儲存設定失敗:\",\n \"updateFailed\": \"更新失敗:\",\n \"checkUpdateFailed\": \"檢查更新失敗:\",\n \"openConfigFolderFailed\": \"開啟設定資料夾失敗:\",\n \"selectConfigDirFailed\": \"選擇設定目錄失敗:\",\n \"getDefaultConfigDirFailed\": \"取得預設設定目錄失敗:\",\n \"openReleaseNotesFailed\": \"開啟更新日誌失敗:\"\n },\n \"providerForm\": {\n \"supplierName\": \"供應商名稱\",\n \"supplierNameRequired\": \"供應商名稱 *\",\n \"supplierNamePlaceholder\": \"例如:Anthropic 官方\",\n \"websiteUrl\": \"官網位址\",\n \"websiteUrlPlaceholder\": \"https://example.com(選填)\",\n \"apiEndpoint\": \"請求位址\",\n \"apiEndpointPlaceholder\": \"https://your-api-endpoint.com\",\n \"codexApiEndpointPlaceholder\": \"https://your-api-endpoint.com/v1\",\n \"manageAndTest\": \"管理與測速\",\n \"configContent\": \"設定內容\",\n \"officialNoApiKey\": \"官方登入無需填寫 API Key,直接儲存即可\",\n \"codexOfficialNoApiKey\": \"官方無需填寫 API Key,直接儲存即可\",\n \"codexApiKeyAutoFill\": \"只需要填這裡,下方 auth.json 會自動填寫\",\n \"apiKeyAutoFill\": \"只需要填這裡,下方設定會自動填寫\",\n \"cnOfficialApiKeyHint\": \"只需填寫 API Key,請求位址已預設\",\n \"aggregatorApiKeyHint\": \"只需填寫 API Key,請求位址已預設\",\n \"thirdPartyApiKeyHint\": \"只需填寫 API Key,請求位址已預設\",\n \"customApiKeyHint\": \"自訂設定需手動填寫所有必要欄位\",\n \"omoHint\": \"OMO 設定管理 Agent 模型指派,相容 oh-my-openagent.jsonc / oh-my-opencode.jsonc\",\n \"officialHint\": \"官方供應商使用瀏覽器登入,無需設定 API Key\",\n \"getApiKey\": \"取得 API Key\",\n \"partnerPromotion\": {\n \"sudocode\": \"SudoCode 讓 Claude Code 與 Claude Desktop 使用 Claude Opus 4.8,Codex 使用 GPT-5.6,一個 Key 統一管理。CC Switch 使用者註冊並加入 QQ 群 726213516,聯絡群主領取人民幣 ¥10 試用額度。\",\n \"code0\": \"code0.ai 是面向開發者的 AI 程式設計服務平台,支援 Claude Code、Codex、Gemini。CC Switch 使用者專屬福利:透過官網聯繫客服即可領取測試額度!\",\n \"nekocode\": \"NekoCode 為開發者提供穩定、高效、可靠的 Claude、Codex 等 AI 模型 API 中轉服務,價格透明、按量計費。CC Switch 使用者專享 9 折:透過上方連結註冊,儲值時輸入優惠碼 cc-switch 即享 9 折優惠!\",\n \"packycode\": \"PackyCode 是 CC Switch 的官方合作夥伴,使用此連結註冊並在儲值時填寫「cc-switch」優惠碼,可以享受 9 折優惠\",\n \"apikeyfun\": \"APIKEY.FUN 為 CC Switch 的使用者提供了特別優惠,透過專屬連結註冊,可享受最高儲值永久 95 折優惠。\",\n \"apinebula\": \"APINEBULA 為 CC Switch 使用者提供特別優惠:使用專屬連結註冊並在儲值時填寫「ccswitch」優惠碼,可享 9 折優惠。\",\n \"atlascloud\": \"Atlas Cloud 是一個全模態 AI 推理平台,透過單一 API 為開發者提供影片生成、圖像生成及 LLM 接入。立即查看全新「編程計畫」優惠,取得更具性價比的 API 接入。\",\n \"patewayai\": \"PatewayAI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊可以獲得 3 美元額度。\",\n \"claudeapi\": \"ClaudeAPI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊可以領取測試額度。\",\n \"claudecn\": \"ClaudeCN 是一家實體企業營運的企業級 AI 中繼平台,支援企業採購流程,可對公打款、簽約,服務合規有保障。\",\n \"runapi\": \"RunAPI 為 CC Switch 的使用者提供了特別福利,透過此連結註冊並聯繫客服可領取 ¥14 免費額度。\",\n \"minimax_cn\": \"MiniMax Coding Plan 特惠,Starter 方案 9.9 元起\",\n \"minimax_en\": \"MiniMax Coding Plan 黑五特惠,Starter 方案現僅 $2 / 月(2 折優惠!)\",\n \"opencode_go\": \"使用此連結訂閱 OpenCode Go,首月僅需 $5,並可獲得額外的 $5 額度!\",\n \"dmxapi\": \"Claude Code 專屬模型 3.4 折優惠進行中!\",\n \"cubence\": \"Cubence 是 CC Switch 的官方合作夥伴,使用此連結註冊並在儲值時填寫「CCSWITCH」優惠碼,每次儲值均可享受 9 折優惠\",\n \"aigocode\": \"AIGoCode 是 CC Switch 的官方合作夥伴,使用此連結註冊首次儲值時可以獲得 10% 額度獎勵!\",\n \"rightcode\": \"RightCode 是 CC Switch 的官方合作夥伴,使用此連結註冊每次儲值均可贈送 5% 額外額度!\",\n \"aicodemirror\": \"AICodeMirror 是 CC Switch 的官方合作夥伴,使用此連結註冊可享受 8 折優惠!\",\n \"aicoding\": \"AI Coding 為 CC Switch 的使用者提供了特殊優惠,首次儲值可以享受 9 折優惠!\",\n \"crazyrouter\": \"CrazyRouter 為 CC Switch 的使用者提供了特殊優惠,首次儲值贈送 30% 額外額度!\",\n \"sssaicode\": \"SSAI Code 為 CC Switch 的使用者提供了特殊優惠,每次儲值贈送額外 $10 額度!\",\n \"siliconflow\": \"矽基流動是 CC Switch 的官方合作夥伴\",\n \"ucloud\": \"優雲智算為 CC Switch 的使用者提供了特殊優惠,透過此連結註冊,可以獲得 5 元平台體驗金!\",\n \"micu\": \"Micu 是 CC Switch 的官方合作夥伴\",\n \"etok\": \"官網加入 ETok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Coding Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 Coding Plan,新客戶首兩個月享 2.5 折優惠,再用專屬邀請碼 6J6FV5N2 領取獎勵疊加 9.5 折,低至 9.4 元/月!\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\",\n \"ccsub\": \"CCSub 是穩定、實惠的 AI API 中轉平台,Claude Code 官方訂閱的超強平替,一個 Key 覆蓋全部模型,價格約為官方 1/3。\",\n \"subrouter\": \"SubRouter 是中轉站聚合平台,一個 Key 可接入多個模型與供應商。\",\n \"qiniu\": \"七牛雲是多協定 AI 中轉平台,一個 Key 即可呼叫原生 Claude、GPT、Gemini 模型,相容 Anthropic、OpenAI、Gemini 多種介面。\",\n \"fenno\": \"Fenno.ai 是穩定高效的 API 中轉服務商,可接入多種主流模型。CC Switch 使用者專屬福利:透過此連結訂閱 9.9 元 /150 刀額度的超值 Coding Plan!\",\n \"zetaapi\": \"ZetaAPI 主打模型不摻水、不降智,價格低至官方價 35 折。CC Switch 使用者專屬福利:透過此連結註冊,首次充值使用優惠碼 CC-SWITCH 即可享受九折優惠!\",\n \"teamorouter\": \"TeamoRouter 是企業級 Agentic LLM 閘道,價格最高可達 90% 折扣。為 CC Switch 的使用者提供了特別福利:新使用者透過此連結註冊,首次充值享 10% 折扣!\",\n \"unity2\": \"Unity2.ai 為 CC Switch 使用者提供專屬福利:透過此連結註冊可領取 $2 餘額,加入官方群再送 $10,最高可領 $12 免費額度!\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\n \"qiniu\": \"七牛雲\",\n \"openrouter\": \"OpenRouter\",\n \"deepseek\": \"DeepSeek\",\n \"together\": \"Together AI\"\n },\n \"parameterConfig\": \"參數設定 - {{name}} *\",\n \"mainModel\": \"主模型 (選填)\",\n \"mainModelPlaceholder\": \"例如: GLM-4.6\",\n \"fastModel\": \"快速模型 (選填)\",\n \"fastModelPlaceholder\": \"例如: GLM-4.5-Air\",\n \"modelHint\": \"留空將使用供應商的預設模型\",\n \"apiHint\": \"填寫相容 Claude API 的伺服器端點位址,請勿以斜線結尾\",\n \"apiHintOAI\": \"填寫相容 OpenAI Chat Completions 的伺服器端點位址,請勿以斜線結尾\",\n \"apiHintGeminiNative\": \"建議填寫 Gemini Native 的 base URL,例如 https://generativelanguage.googleapis.com 或 https://generativelanguage.googleapis.com/v1beta;代理伺服器會自動補齊 models/*:generateContent\",\n \"codexApiHint\": \"填寫相容 OpenAI Response 格式的伺服器端點位址\",\n \"fillSupplierName\": \"請填寫供應商名稱\",\n \"fillConfigContent\": \"請填寫設定內容\",\n \"fillParameter\": \"請填寫 {{label}}\",\n \"fillTemplateValue\": \"請填寫 {{label}}\",\n \"endpointRequired\": \"非官方供應商請填寫 API 端點\",\n \"apiKeyRequired\": \"非官方供應商請填寫 API Key\",\n \"softValidation\": {\n \"title\": \"設定存在以下問題\",\n \"hint\": \"仍要儲存嗎?儲存後切換此供應商時可能失敗,可以之後再補全。\",\n \"saveAnyway\": \"仍要儲存\"\n },\n \"configJsonError\": \"設定 JSON 格式錯誤,請檢查語法\",\n \"authJsonRequired\": \"auth.json 必須是 JSON 物件\",\n \"authJsonError\": \"auth.json 格式錯誤,請檢查 JSON 語法\",\n \"fillAuthJson\": \"請填寫 auth.json 設定\",\n \"fillApiKey\": \"請填寫 OPENAI_API_KEY\",\n \"visitWebsite\": \"造訪 {{url}}\",\n \"anthropicModel\": \"主模型\",\n \"anthropicSmallFastModel\": \"快速模型\",\n \"apiFormat\": \"API 格式\",\n \"apiFormatHint\": \"選擇供應商 API 的輸入格式\",\n \"customUserAgent\": \"自訂 User-Agent\",\n \"customUserAgentHint\": \"僅在開啟本地路由/代理接管後生效,會取代轉發至供應商 API 請求中的 User-Agent。\",\n \"customUserAgentInvalid\": \"User-Agent 不能包含控制字元(如換行字元),否則將被忽略。\",\n \"customUserAgentPresets\": \"預設\",\n \"localProxyRequestOverrides\": \"本地代理請求覆蓋\",\n \"localProxyRequestOverridesHint\": \"僅在本地路由/代理接管後生效,套用於協定轉換後的上游請求。\",\n \"localProxyRequestOverridesInvalid\": \"本地代理請求覆蓋格式錯誤:{{error}}\",\n \"localProxyHeaderOverrides\": \"Header 覆蓋\",\n \"localProxyHeaderOverridesInvalidDetail\": \"Header 覆蓋格式錯誤:{{error}}\",\n \"localProxyBodyOverrides\": \"Body 覆蓋\",\n \"localProxyBodyOverridesInvalidDetail\": \"Body 覆蓋格式錯誤:{{error}}\",\n \"fullUrlLabel\": \"完整 URL\",\n \"fullUrlEnabled\": \"完整 URL 模式\",\n \"fullUrlDisabled\": \"標記為完整 URL\",\n \"fullUrlHint\": \"請填寫完整請求 URL,並且必須開啟路由後使用;路由將直接使用此 URL,不拼接路徑\",\n \"fullUrlHintGeminiNative\": \"Gemini Native 下,完整 URL 模式同時相容兩類位址:1. 官方/標準 Gemini URL,代理伺服器會依模型和串流參數自動標準化;2. 自訂 relay 的完整 URL,代理伺服器會盡量原樣使用,只補齊查詢參數,不再強制附加 models 路徑\",\n \"apiFormatAnthropic\": \"Anthropic Messages (原生)\",\n \"apiFormatOpenAIChat\": \"OpenAI Chat Completions (需開啟路由)\",\n \"apiFormatOpenAIResponses\": \"OpenAI Responses API (需開啟路由)\",\n \"apiFormatGeminiNative\": \"Gemini Native generateContent (需開啟路由)\",\n \"codexApiFormatResponses\": \"OpenAI Responses API (原生)\",\n \"codexApiFormatOpenAIChat\": \"OpenAI Chat Completions (需開啟路由)\",\n \"codexApiFormatHint\": \"選擇供應商真實支援的 Codex API 格式;設定仍保持 Responses 以相容新版 Codex,Chat Completions 會透過本地路由自動轉換。\",\n \"authField\": \"驗證欄位\",\n \"authFieldAuthToken\": \"ANTHROPIC_AUTH_TOKEN(預設)\",\n \"authFieldApiKey\": \"ANTHROPIC_API_KEY\",\n \"authFieldHint\": \"選擇寫入設定的驗證環境變數名稱\",\n \"apiHintResponses\": \"填寫相容 OpenAI Responses API 的伺服器端點位址,請勿以斜線結尾\",\n \"anthropicDefaultHaikuModel\": \"Haiku 預設模型\",\n \"anthropicDefaultSonnetModel\": \"Sonnet 預設模型\",\n \"anthropicDefaultOpusModel\": \"Opus 預設模型\",\n \"modelPlaceholder\": \"\",\n \"smallModelPlaceholder\": \"\",\n \"haikuModelPlaceholder\": \"\",\n \"modelHelper\": \"選填:指定預設使用的 Claude 模型,留空則使用系統預設。\",\n \"modelMappingLabel\": \"模型對應\",\n \"modelMappingHint\": \"顯示名稱只影響 /model 選單;1M 只是提供給 Claude Code 的上下文能力聲明。\",\n \"modelRoleLabel\": \"模型角色\",\n \"modelRoleSonnet\": \"Sonnet\",\n \"modelRoleOpus\": \"Opus\",\n \"modelRoleFable\": \"Fable\",\n \"modelRoleHaiku\": \"Haiku\",\n \"modelRoleSubagent\": \"Subagent\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"modelNoDisplayName\": \"不顯示於 /model 選單\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"用於未明確落到 Sonnet、Opus、Fable、Haiku 角色的請求。使用第三方/中轉端點時建議填寫:否則這些請求(含 Haiku 背景子任務)會以原始 Claude 模型名透傳給上游,可能因上游無此模型而報錯。官方端點可留空。\",\n \"quickSetModels\": \"一鍵設定\",\n \"quickSetSuccess\": \"已將模型名稱套用至所有角色\",\n \"advancedOptionsToggle\": \"進階選項\",\n \"advancedOptionsHint\": \"包含 API 格式、驗證欄位、模型對應等設定。大多數場景下保持預設即可。\",\n \"categoryOfficial\": \"官方\",\n \"categoryCnOfficial\": \"開源官方\",\n \"categoryAggregation\": \"聚合服務\",\n \"categoryThirdParty\": \"第三方\",\n \"fetchModels\": \"取得模型清單\",\n \"fetchingModels\": \"正在取得...\",\n \"fetchModelsSuccess\": \"取得 {{count}} 個模型\",\n \"fetchModelsFailed\": \"取得模型清單失敗\",\n \"fetchModelsEmpty\": \"未找到可用模型\",\n \"fetchModelsNeedApiKey\": \"請先填寫 API Key\",\n \"fetchModelsNeedEndpoint\": \"請先填寫 API 端點\",\n \"fetchModelsNeedConfig\": \"請先填寫 API 端點和 API Key\",\n \"fetchModelsAuthFailed\": \"API Key 無效或無權限\",\n \"fetchModelsNotSupported\": \"該供應商不支援取得模型清單\",\n \"fetchModelsEndpointNotFound\": \"未找到可用的模型清單端點,請檢查 Base URL 或確認供應商是否開放該 API\",\n \"fetchModelsTimeout\": \"請求逾時,請檢查網路連線\"\n },\n \"copilot\": {\n \"authSection\": \"GitHub Copilot 驗證\",\n \"authStatus\": \"驗證狀態\",\n \"authenticated\": \"已驗證: {{username}}\",\n \"notAuthenticated\": \"未驗證\",\n \"loginWithGitHub\": \"使用 GitHub 登入\",\n \"loginRequired\": \"請先登入 GitHub Copilot\",\n \"waitingForAuth\": \"等待授權中...\",\n \"enterCode\": \"請在瀏覽器中輸入驗證碼:\",\n \"logout\": \"登出\",\n \"authSuccess\": \"GitHub Copilot 驗證成功\",\n \"authFailed\": \"驗證失敗:{{error}}\",\n \"authTimeout\": \"驗證逾時,請重試\",\n \"tokenExpired\": \"Token 已過期,請重新驗證\",\n \"accountCount\": \"{{count}} 個帳號\",\n \"selectAccount\": \"選擇帳號\",\n \"selectAccountPlaceholder\": \"選擇一個 GitHub 帳號\",\n \"useDefaultAccount\": \"使用預設帳號\",\n \"loggedInAccounts\": \"已登入帳號\",\n \"defaultAccount\": \"預設\",\n \"selected\": \"已選取\",\n \"removeAccount\": \"移除帳號\",\n \"setAsDefault\": \"設為預設\",\n \"addAnotherAccount\": \"新增其他帳號\",\n \"logoutAll\": \"登出所有帳號\",\n \"retry\": \"重試\",\n \"copyCode\": \"複製程式碼\",\n \"migrationFailed\": \"舊驗證資料遷移失敗:{{error}}\",\n \"loadModelsFailed\": \"載入 Copilot 模型清單失敗\",\n \"deploymentType\": \"GitHub 部署類型\",\n \"deploymentGitHubCom\": \"GitHub.com\",\n \"deploymentEnterprise\": \"GitHub Enterprise Server\",\n \"enterpriseDomainPlaceholder\": \"例如:company.ghe.com\"\n },\n \"codexOauth\": {\n \"authStatus\": \"驗證狀態\",\n \"notAuthenticated\": \"未驗證\",\n \"loginWithChatGPT\": \"使用 ChatGPT 登入\",\n \"loginRequired\": \"請先登入 ChatGPT 帳號\",\n \"waitingForAuth\": \"等待授權中...\",\n \"enterCode\": \"請在瀏覽器中輸入以下驗證碼:\",\n \"accountCount\": \"{{count}} 個帳號\",\n \"selectAccount\": \"選擇帳號\",\n \"selectAccountPlaceholder\": \"選擇一個 ChatGPT 帳號\",\n \"useDefaultAccount\": \"使用預設帳號\",\n \"loggedInAccounts\": \"已登入帳號\",\n \"defaultAccount\": \"預設\",\n \"selected\": \"已選取\",\n \"removeAccount\": \"移除帳號\",\n \"setAsDefault\": \"設為預設\",\n \"addAnotherAccount\": \"新增其他帳號\",\n \"logoutAll\": \"登出所有帳號\",\n \"retry\": \"重試\",\n \"copyCode\": \"複製程式碼\",\n \"fastMode\": \"FAST 模式\",\n \"fastModeDescription\": \"發送 service_tier=\\\"priority\\\" 換取更低延遲。預設關閉——開啟後會以更高速率消耗 ChatGPT 配額。\"\n },\n \"endpointTest\": {\n \"title\": \"請求位址管理\",\n \"endpoints\": \"個端點\",\n \"autoSelect\": \"自動選擇\",\n \"testSpeed\": \"測速\",\n \"testing\": \"測速中\",\n \"addEndpointPlaceholder\": \"https://api.example.com\",\n \"done\": \"完成\",\n \"noEndpoints\": \"暫無端點\",\n \"failed\": \"失敗\",\n \"enterValidUrl\": \"請輸入有效的 URL\",\n \"invalidUrlFormat\": \"URL 格式不正確\",\n \"onlyHttps\": \"僅支援 HTTP/HTTPS\",\n \"urlExists\": \"該位址已存在\",\n \"saveFailed\": \"儲存失敗,請重試\",\n \"loadEndpointsFailed\": \"載入自訂端點失敗:\",\n \"addEndpointFailed\": \"新增自訂端點失敗:\",\n \"removeEndpointFailed\": \"刪除自訂端點失敗:\",\n \"removeFailed\": \"刪除失敗:{{error}}\",\n \"updateLastUsedFailed\": \"更新端點使用時間失敗\",\n \"pleaseAddEndpoint\": \"請先新增端點\",\n \"testUnavailable\": \"測速功能不可用\",\n \"noResult\": \"未回傳結果\",\n \"testFailed\": \"測速失敗:{{error}}\",\n \"empty\": \"暫無端點\"\n },\n \"providerAdvanced\": {\n \"pricingConfig\": \"計費設定\",\n \"useCustomPricing\": \"使用獨立設定\",\n \"pricingConfigDesc\": \"為此供應商設定獨立的計費參數,不啟用時使用全域預設設定。\",\n \"costMultiplier\": \"成本倍率\",\n \"costMultiplierPlaceholder\": \"留空使用全域預設(1)\",\n \"costMultiplierHint\": \"實際成本 = 基礎成本 × 倍率,支援小數如 1.5\",\n \"pricingModelSourceLabel\": \"計費模式\",\n \"pricingModelSourceInherit\": \"繼承全域預設\",\n \"pricingModelSourceRequest\": \"請求模型\",\n \"pricingModelSourceResponse\": \"回傳模型\",\n \"pricingModelSourceHint\": \"選擇依請求模型還是回傳模型進行定價比對\"\n },\n \"grokConfig\": {\n \"authJson\": \"Grok API Key (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"xai-your-api-key\\\"\\n}\",\n \"authJsonHint\": \"供應商共用 API Key;每個 [model.*] 也可獨立設定 api_key 或 env_key\",\n \"profileToml\": \"Grok Profile (TOML)\",\n \"profileTomlHint\": \"管理 endpoints、models、subagents 和 model.*;切換時保留其他 Grok 全域設定\",\n \"defaultModelPlaceholder\": \"例如: grok-4.5\",\n \"defaultModelHint\": \"Grok Build 預設使用的模型,可隨時修改。留空時保留目前 Profile 的預設模型。\",\n \"advancedSectionHint\": \"包含 Grok 原生 API Backend、User-Agent 與本機代理請求覆寫。\",\n \"editGlobalConfig\": \"編輯 Grok 全域設定\",\n \"addToGlobalConfig\": \"加入全域設定\",\n \"addedToGlobalConfig\": \"目前 Grok Profile 已加入全域設定\",\n \"globalConfigTitle\": \"Grok Build 全域 config.toml\",\n \"globalConfigHint\": \"這裡編輯本機實際設定。供應商切換只替換 endpoints、models、subagents 與 model.*,其他設定會保留;每次儲存都會自動備份。\",\n \"globalConfigSaved\": \"Grok 全域設定已儲存\",\n \"applyPrivacy\": \"套用隱私保護\",\n \"privacyApplied\": \"隱私保護設定已寫入\"\n },\n \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 設定內容\",\n \"authJsonStorageHint\": \"代理接管中顯示的是儲存的 auth 配置,不是即時 auth.json\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\n \"configTomlStorageHint\": \"代理接管中顯示的是儲存的 config 配置,不是即時 config.toml\",\n \"proxyTakeoverStorageNotice\": \"💡 目前應用處於代理接管狀態。這裡編輯的是儲存的供應商配置,真實 live 配置已由代理暫時接管\",\n \"writeCommonConfig\": \"套用通用設定\",\n \"editCommonConfig\": \"編輯通用設定\",\n \"editCommonConfigTitle\": \"編輯 Codex 通用設定片段\",\n \"commonConfigHint\": \"該片段會在勾選「套用通用設定」時附加到 config.toml 末尾\",\n \"enableGoalMode\": \"啟用 Goal mode\",\n \"enableRemoteCompaction\": \"啟用遠端壓縮\",\n \"remoteCompactionHint\": \"開啟後會將目前 model_providers 條目的 name 寫為 OpenAI,讓 Codex 嘗試使用遠端壓縮。\",\n \"apiUrlLabel\": \"API 請求位址\",\n \"extractFromCurrent\": \"從編輯內容擷取\",\n \"extractNoCommonConfig\": \"目前編輯內容沒有可擷取的通用設定\",\n \"extractFailed\": \"擷取失敗:{{error}}\",\n \"saveFailed\": \"儲存失敗:{{error}}\",\n \"modelNameHint\": \"指定使用的模型,將自動更新到 config.toml 中\",\n \"modelName\": \"模型名稱\",\n \"modelNamePlaceholder\": \"例如: gpt-5-codex\",\n \"contextWindow1M\": \"1M 上下文視窗\",\n \"autoCompactLimit\": \"壓縮閾值\",\n \"autoCompactLimitHint\": \"上下文 token 數達到此閾值時自動壓縮歷史\",\n \"autoCompactLimitPlaceholder\": \"例如: 90000\",\n \"upstreamFormatLabel\": \"上游格式\",\n \"upstreamFormatHint\": \"供應商原生為 Responses API 就選 Responses(直連,不轉換格式);使用 Chat Completions 協定就選 Chat;供應商只提供原生 Anthropic Messages 協定就選 Anthropic Messages。Chat 與 Anthropic Messages 均需開啟路由接管才能轉換為 Responses。\",\n \"upstreamFormatChat\": \"Chat Completions(需開啟路由)\",\n \"upstreamFormatResponses\": \"Responses(原生)\",\n \"upstreamFormatAnthropic\": \"Anthropic Messages(需開啟路由)\",\n \"anthropicAuthFieldLabel\": \"認證欄位\",\n \"anthropicAuthFieldAuthToken\": \"ANTHROPIC_AUTH_TOKEN(Authorization)\",\n \"anthropicAuthFieldApiKey\": \"ANTHROPIC_API_KEY(x-api-key)\",\n \"anthropicAuthFieldHint\": \"選擇閘道接收 API Key 的請求標頭:ANTHROPIC_AUTH_TOKEN 傳送 Authorization: Bearer;ANTHROPIC_API_KEY 傳送 x-api-key。兩者只傳其一。\",\n \"impersonateClaudeCodeLabel\": \"模擬 Claude Code 用戶端\",\n \"impersonateClaudeCodeHint\": \"閘道或其上游限制只能透過 Claude Code 使用時開啟:偽裝 User-Agent、anthropic-beta、x-app 請求標頭,並在系統提示首行注入 Claude Code 身分。\",\n \"maxOutputTokensLabel\": \"最大輸出 tokens\",\n \"maxOutputTokensPlaceholder\": \"留空則使用預設 8192\",\n \"maxOutputTokensHint\": \"Codex 不會把 model_max_output_tokens 寫進請求體,預設上限 8192 容易在長回答或深度思考時被截斷(stop_reason=max_tokens)。此處設定會作為 Anthropic 的 max_tokens 覆蓋請求值。請勿超過該模型/閘道的真實輸出上限,否則可能 400。留空使用預設 8192。\",\n \"promptCacheRoutingLabel\": \"提示詞快取路由\",\n \"promptCacheRoutingAuto\": \"自動(建議)\",\n \"promptCacheRoutingEnabled\": \"開啟\",\n \"promptCacheRoutingDisabled\": \"關閉\",\n \"promptCacheRoutingHint\": \"自動模式僅對已確認相容的上游傳送 prompt_cache_key;開啟可用於其他相容閘道,關閉可避免嚴格閘道因未知欄位回傳 400。只使用客戶端提供的穩定工作階段 ID。\",\n \"defaultModelLabel\": \"預設模型\",\n \"defaultModelPlaceholder\": \"例如: gpt-5.6\",\n \"defaultModelHint\": \"Codex 預設請求的模型,隨時可改,無需等待軟體更新供應商範本。留空且設定了模型映射時,預設使用映射第一行。\",\n \"defaultModelNotInCatalog\": \"該模型不在模型映射中,Codex 的 /model 選單不會列出它(直接請求仍然有效)。\",\n \"addToModelMapping\": \"加入映射\",\n \"modelMappingTitle\": \"模型映射\",\n \"modelMappingHint\": \"產生 Codex model_catalog_json,讓 /model 指令顯示這些第三方模型名;表中條目按填寫內容原樣儲存。修改後需要重新啟動 Codex 才能重新整理模型清單。\",\n \"addCatalogModel\": \"新增模型\",\n \"catalogDisplayName\": \"顯示名稱\",\n \"catalogModelId\": \"模型 ID\",\n \"catalogColumnDisplay\": \"選單顯示名\",\n \"catalogColumnModel\": \"實際請求模型\",\n \"catalogColumnContext\": \"上下文視窗\",\n \"catalogDisplayNamePlaceholder\": \"例如: DeepSeek V4 Flash\",\n \"catalogModelPlaceholder\": \"例如: deepseek-v4-flash\",\n \"contextWindow\": \"上下文視窗\",\n \"contextWindowPlaceholder\": \"例如: 128000\",\n \"upstreamModelName\": \"上游模型名稱\",\n \"upstreamModelNameHint\": \"Chat 格式下這裡填寫真實上游模型;路由會把 Codex 的 Responses 請求轉換為 Chat Completions 並保持該模型。\",\n \"modelMetadataAdvanced\": \"模型中繼資料進階選項\",\n \"modelMetadataAdvancedHint\": \"用於未知模型的 Codex 中繼資料覆寫;清空欄位會從 config.toml 刪除對應設定。\",\n \"reasoningModeToggle\": \"支援思考模式\",\n \"reasoningModeHint\": \"上游 Chat Completions 介面支援開啟或關閉 thinking 時啟用。Kimi、GLM、Qwen 等通常屬於這一類。\",\n \"reasoningEffortToggle\": \"支援思考等級\",\n \"reasoningEffortHint\": \"上游支援 low/high/max 等思考深度控制時啟用。啟用後會自動啟用思考模式,並把 Codex 的 reasoning.effort 轉成上游 Chat 參數。\",\n \"reasoningGroupTitle\": \"思考能力\",\n \"reasoningSectionHint\": \"預設供應商已自動設定;自訂供應商會按名稱/位址自動推斷。僅當自動識別不準時才需手動覆寫。\",\n \"advancedSectionHint\": \"包含上游格式、模型映射、思考能力與自訂 User-Agent。使用 Chat Completions 協定的供應商需開啟路由接管才能使用。\"\n },\n \"geminiConfig\": {\n \"envFile\": \"環境變數 (.env)\",\n \"envFileHint\": \"使用 .env 格式設定 Gemini 環境變數\",\n \"configJson\": \"設定檔 (config.json)\",\n \"configJsonHint\": \"使用 JSON 格式設定 Gemini 擴充參數(選填)\",\n \"writeCommonConfig\": \"套用通用設定\",\n \"editCommonConfig\": \"編輯通用設定\",\n \"editCommonConfigTitle\": \"編輯 Gemini 通用設定片段\",\n \"commonConfigHint\": \"該片段會在勾選「套用通用設定」時寫入 Gemini 的 .env(不允許包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)\",\n \"extractFromCurrent\": \"從編輯內容擷取\",\n \"extractNoCommonConfig\": \"目前編輯內容沒有可擷取的通用設定\",\n \"extractFailed\": \"擷取失敗:{{error}}\",\n \"saveFailed\": \"儲存失敗:{{error}}\",\n \"extractedConfigInvalid\": \"擷取的設定格式錯誤\",\n \"invalidJsonFormat\": \"通用設定片段格式錯誤(必須是有效的 JSON)\",\n \"commonConfigInvalidKeys\": \"通用設定片段不能包含 GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_KEY(發現:{{keys}})\",\n \"commonConfigInvalidValues\": \"通用設定片段的值必須是字串\",\n \"noCommonConfigToApply\": \"通用設定片段為空或沒有可寫入的內容\",\n \"configMergeFailed\": \"設定合併失敗:{{error}}\",\n \"configReplaceFailed\": \"設定替換失敗:{{error}}\"\n },\n \"opencode\": {\n \"npmPackage\": \"API 格式\",\n \"selectPackage\": \"選擇 API 格式\",\n \"npmPackageHint\": \"選擇 AI 服務的 API 格式\",\n \"baseUrl\": \"Base URL\",\n \"baseUrlHint\": \"自訂 API 端點位址\",\n \"models\": \"模型設定\",\n \"modelsHint\": \"設定可用的模型及其顯示名稱\",\n \"addModel\": \"新增模型\",\n \"modelId\": \"模型 ID\",\n \"modelName\": \"顯示名稱\",\n \"noModels\": \"暫無模型設定\",\n \"modelsRequired\": \"請至少新增一個模型設定\",\n \"toggleModelDetails\": \"展開或收合模型詳細資料\",\n \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"headers\": \"請求標頭\",\n \"headersHint\": \"隨供應商請求傳送的選用 HTTP 請求標頭,例如 HTTP-Referer 或 X-Title。\",\n \"addHeader\": \"新增請求標頭\",\n \"headerName\": \"請求標頭\",\n \"headerValue\": \"值\",\n \"headerNamePlaceholder\": \"X-Title\",\n \"headerValuePlaceholder\": \"CC Switch\",\n \"removeHeader\": \"刪除請求標頭\",\n \"noHeaders\": \"暫無自訂請求標頭\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\n \"modelLimits\": \"Token 限制\",\n \"limitContext\": \"上下文\",\n \"limitOutput\": \"輸出\",\n \"modelExtraFields\": \"模型屬性\",\n \"noModelExtraFields\": \"模型屬性 (variants, cost 等),點擊 + 新增\",\n \"modelExtraFieldKeyPlaceholder\": \"variants\",\n \"sdkOptions\": \"SDK 選項\",\n \"modelOptionKeyPlaceholder\": \"provider\",\n \"modelOptionValuePlaceholder\": \"{\\\"order\\\": [\\\"baseten\\\"]}\"\n },\n \"providerPreset\": {\n \"label\": \"預設供應商\",\n \"custom\": \"自訂設定\",\n \"other\": \"其他\",\n \"hint\": \"選擇預設後可繼續調整下方欄位。\",\n \"searchTooltip\": \"搜尋預設\",\n \"searchAriaLabel\": \"搜尋預設供應商\",\n \"searchPlaceholder\": \"搜尋名稱、官網或分類...\",\n \"sortAriaLabel\": \"切換預設排序\",\n \"sortNameAscTooltip\": \"按名稱 A-Z 排序\",\n \"sortOriginalTooltip\": \"恢復原始順序\",\n \"noSearchResults\": \"沒有相符的預設供應商。\"\n },\n \"usage\": {\n \"title\": \"使用統計\",\n \"subtitle\": \"查看 AI 模型的使用情況和成本統計\",\n \"today\": \"24 小時\",\n \"last7days\": \"7 天\",\n \"last30days\": \"30 天\",\n \"presetToday\": \"當天\",\n \"preset1d\": \"1d\",\n \"preset7d\": \"7d\",\n \"preset14d\": \"14d\",\n \"preset30d\": \"30d\",\n \"totalRequests\": \"總請求數\",\n \"totalCost\": \"總成本\",\n \"cost\": \"成本\",\n \"perMillion\": \"(每百萬)\",\n \"trends\": \"使用趨勢\",\n \"rangeToday\": \"過去 24 小時 (按小時)\",\n \"rangeLast7Days\": \"過去 7 天\",\n \"rangeLast30Days\": \"過去 30 天\",\n \"totalTokens\": \"新產生 Token (Input+Output)\",\n \"cacheTokens\": \"快取 Token\",\n \"realTotal\": \"真實消耗 Tokens\",\n \"realTotalHint\": \"Input + Output + 快取建立 + 快取命中\",\n \"freshInput\": \"新增輸入\",\n \"cacheHitRate\": \"快取命中率\",\n \"requestLogs\": \"請求日誌\",\n \"providerStats\": \"Provider 統計\",\n \"modelStats\": \"模型統計\",\n \"time\": \"時間\",\n \"provider\": \"供應商\",\n \"billingModel\": \"計費模型\",\n \"inputTokens\": \"輸入\",\n \"outputTokens\": \"輸出\",\n \"cacheReadTokens\": \"快取命中\",\n \"cacheCreationTokens\": \"快取建立\",\n \"timingInfo\": \"耗時/首字\",\n \"status\": \"狀態\",\n \"multiplier\": \"倍率\",\n \"requestModel\": \"請求模型\",\n \"responseModel\": \"回傳模型\",\n \"noData\": \"暫無資料\",\n \"unknownProvider\": \"未知供應商\",\n \"stream\": \"串流\",\n \"nonStream\": \"非串流\",\n \"source\": \"來源\",\n \"requestsLabel\": \"次請求\",\n \"costLabel\": \"總成本\",\n \"appFilter\": {\n \"all\": \"全部\",\n \"claude\": \"Claude Code\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\"\n },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\",\n \"opencode_session\": \"OpenCode 工作階段日誌\"\n },\n \"sessionSync\": {\n \"trigger\": \"同步工作階段日誌\",\n \"import\": \"匯入工作階段\",\n \"resync\": \"同步\",\n \"imported\": \"從工作階段日誌匯入了 {{count}} 筆紀錄\",\n \"upToDate\": \"工作階段日誌已是最新\",\n \"failed\": \"工作階段同步失敗\"\n },\n \"totalRecords\": \"共 {{total}} 筆紀錄\",\n \"goToPage\": \"跳轉\",\n \"pageInputPlaceholder\": \"頁碼\",\n \"modelPricing\": \"模型定價\",\n \"loadPricingError\": \"載入定價資料失敗\",\n \"modelPricingDesc\": \"設定各模型的 Token 成本\",\n \"noPricingData\": \"暫無定價資料。點擊「新增」加入模型定價設定。\",\n \"model\": \"模型\",\n \"pricingModel\": \"計價模型\",\n \"displayName\": \"顯示名稱\",\n \"inputCost\": \"輸入成本\",\n \"outputCost\": \"輸出成本\",\n \"cacheReadCost\": \"快取命中\",\n \"cacheWriteCost\": \"快取建立\",\n \"deleteConfirmTitle\": \"確認刪除\",\n \"deleteConfirmDesc\": \"確定要刪除此模型定價設定嗎?此操作無法復原。\",\n \"queryFailed\": \"查詢失敗\",\n \"refreshUsage\": \"重新整理用量\",\n \"planUsage\": \"方案用量\",\n \"invalid\": \"已失效\",\n \"total\": \"總:\",\n \"used\": \"已使用:\",\n \"remaining\": \"剩餘:\",\n \"justNow\": \"剛剛\",\n \"minutesAgo\": \"{{count}} 分鐘前\",\n \"hoursAgo\": \"{{count}} 小時前\",\n \"daysAgo\": \"{{count}} 天前\",\n \"multiplePlans\": \"{{count}} 個方案\",\n \"expand\": \"展開\",\n \"collapse\": \"收起\",\n \"modelIdPlaceholder\": \"例如: claude-3-5-sonnet-20241022\",\n \"displayNamePlaceholder\": \"例如: Claude 3.5 Sonnet\",\n \"appType\": \"應用程式類型\",\n \"statusCode\": \"狀態碼\",\n \"allSources\": \"全部來源\",\n \"allModels\": \"全部模型\",\n \"filterBySource\": \"依來源篩選\",\n \"filterByModel\": \"依模型篩選\",\n \"refreshInterval\": \"自動重新整理間隔\",\n \"refreshOff\": \"關閉\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\n \"liveEndTime\": \"結束時間跟隨當前時刻\",\n \"liveEndTimeNow\": \"現在\",\n \"input\": \"Input\",\n \"output\": \"Output\",\n \"cacheWrite\": \"建立\",\n \"cacheWriteNotReported\": \"OpenAI 協定不區分快取建立,僅上報快取命中\",\n \"cacheWritePartial\": \"部分協定(如 OpenAI)不上報快取建立,數值可能偏低\",\n \"cacheRead\": \"命中\",\n \"baseCost\": \"基礎\",\n \"costMultiplier\": \"成本倍率\",\n \"withMultiplier\": \"含倍率\",\n \"requestDetail\": \"請求詳情\",\n \"requestNotFound\": \"請求未找到\",\n \"basicInfo\": \"基本資訊\",\n \"tokenUsage\": \"Token 使用量\",\n \"cacheCreationCost\": \"快取寫入成本\",\n \"costBreakdown\": \"成本明細\",\n \"performance\": \"效能資訊\",\n \"latency\": \"延遲\",\n \"errorMessage\": \"錯誤訊息\",\n \"requests\": \"請求數\",\n \"tokens\": \"Tokens\",\n \"avgCost\": \"平均成本\",\n \"avgLatency\": \"平均延遲\",\n \"successRate\": \"成功率\",\n \"requestId\": \"請求 ID\",\n \"never\": \"從不\",\n \"modelId\": \"模型 ID\",\n \"modelIdRequired\": \"模型 ID 不能為空\",\n \"inputCostPerMillion\": \"輸入成本 (每百萬 tokens, USD)\",\n \"outputCostPerMillion\": \"輸出成本 (每百萬 tokens, USD)\",\n \"invalidPrice\": \"價格必須為非負數\",\n \"invalidTimeRange\": \"請選擇完整的開始/結束時間\",\n \"invalidTimeRangeOrder\": \"開始時間不能晚於結束時間\",\n \"timeRangeTooLarge\": \"時間範圍過大,請縮小範圍\",\n \"addPricing\": \"新增定價\",\n \"editPricing\": \"編輯定價\",\n \"pricingAdded\": \"定價已新增\",\n \"pricingUpdated\": \"定價已更新\",\n \"importFromModelsDev\": \"從 models.dev 匯入\",\n \"modelsDevHint\": \"無需手動填寫,可從 models.dev 選擇模型定價\",\n \"modelsDevPickerTitle\": \"從 models.dev 匯入定價\",\n \"modelsDevPickerDesc\": \"選擇要匯入的模型(價格單位:USD / 百萬 tokens),每次匯入一個\",\n \"modelsDevSearchPlaceholder\": \"搜尋模型或供應商(全量搜尋)...\",\n \"modelsDevAllProviders\": \"全部供應商\",\n \"modelsDevLoadError\": \"載入 models.dev 資料失敗\",\n \"modelsDevRetry\": \"重試\",\n \"modelsDevImportButton\": \"匯入\",\n \"modelsDevImporting\": \"匯入中...\",\n \"modelsDevImported\": \"已匯入 {{name}} 的定價\",\n \"modelsDevNoResults\": \"沒有符合的模型\",\n \"modelsDevTruncated\": \"僅顯示前 {{shown}} 條,共 {{total}} 條結果,請縮小搜尋範圍\",\n \"modelsDevDefaultHint\": \"預設展示最新發布的 {{shown}} 個模型(共 {{total}} 個),輸入關鍵字可全量搜尋\",\n \"cacheReadCostPerMillion\": \"快取讀取成本 (每百萬 tokens, USD)\",\n \"cacheCreationCostPerMillion\": \"快取寫入成本 (每百萬 tokens, USD)\"\n },\n \"usageScript\": {\n \"title\": \"設定用量查詢\",\n \"enableUsageQuery\": \"啟用用量查詢\",\n \"presetTemplate\": \"預設範本\",\n \"requestUrl\": \"請求位址\",\n \"requestUrlPlaceholder\": \"例如:https://api.example.com\",\n \"method\": \"HTTP 方法\",\n \"templateCustom\": \"自訂\",\n \"templateGeneral\": \"通用範本\",\n \"templateNewAPI\": \"NewAPI\",\n \"templateCopilot\": \"GitHub Copilot\",\n \"templateTokenPlan\": \"Token Plan\",\n \"templateBalance\": \"官方\",\n \"templateOfficialSubscription\": \"官方訂閱\",\n \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\n \"officialSubscriptionHint\": \"讀取本機 CLI 的 OAuth 憑證,並呼叫官方介面查詢訂閱額度。預設關閉,只有啟用後才會請求。\",\n \"resetDate\": \"重設日期\",\n \"premiumRequests\": \"Premium 請求\",\n \"credentialsConfig\": \"憑證設定\",\n \"credentialsHint\": \"留空則自動使用供應商設定\",\n \"optional\": \"選填\",\n \"apiKeyPlaceholder\": \"留空則使用供應商的 API Key\",\n \"baseUrlPlaceholder\": \"留空則使用供應商的請求位址\",\n \"baseUrl\": \"請求位址\",\n \"accessToken\": \"Access Token(在個人安全設定裡取得)\",\n \"accessTokenPlaceholder\": \"在「安全設定」裡產生\",\n \"userId\": \"使用者 ID\",\n \"userIdPlaceholder\": \"例如:114514\",\n \"accessKeyId\": \"AccessKey ID\",\n \"secretAccessKey\": \"SecretAccessKey\",\n \"volcengineAkSkHint\": \"火山用量查詢需帳號級 AccessKey ID / Secret(與推理 API Key 不同)。請在火山引擎主控台右上角帳號選單 →「API存取金鑰」中建立。\",\n \"volcengineKeyConsoleLink\": \"金鑰建立網址:\",\n \"zhipuTeamHint\": \"智譜團隊套餐用量查詢需團隊 API Key(沿用本供應商憑據)+ 組織 ID + 專案 ID(作為請求標頭 bigmodel-organization / bigmodel-project)。組織與專案 ID 可在團隊管理後台用量頁的 URL 中找到。\",\n \"zhipuTeamConsoleLink\": \"團隊用量頁:\",\n \"organizationId\": \"組織 ID\",\n \"organizationIdPlaceholder\": \"例如:org-xxxxxx\",\n \"projectId\": \"專案 ID\",\n \"projectIdPlaceholder\": \"例如:proj-xxxxxx\",\n \"defaultPlan\": \"預設方案\",\n \"queryFailedMessage\": \"查詢失敗\",\n \"queryScript\": \"查詢腳本(JavaScript)\",\n \"timeoutSeconds\": \"逾時時間(秒)\",\n \"headers\": \"請求標頭\",\n \"body\": \"請求 Body\",\n \"timeoutHint\": \"範圍: 2-30 秒\",\n \"timeoutMustBeInteger\": \"逾時時間必須為整數,小數部分已忽略\",\n \"timeoutCannotBeNegative\": \"逾時時間不能為負數\",\n \"autoIntervalMinutes\": \"自動查詢間隔(分鐘,0 表示不自動查詢)\",\n \"autoQueryInterval\": \"自動查詢間隔(分鐘)\",\n \"autoQueryIntervalHint\": \"0 表示不自動查詢,建議 5-60 分鐘\",\n \"intervalMustBeInteger\": \"自動查詢間隔必須為整數,小數部分已忽略\",\n \"intervalCannotBeNegative\": \"自動查詢間隔不能為負數\",\n \"intervalAdjusted\": \"自動查詢間隔已調整為 {{value}} 分鐘\",\n \"scriptHelp\": \"腳本編寫說明:\",\n \"configFormat\": \"設定格式:\",\n \"commentOptional\": \"選填\",\n \"commentResponseIsJson\": \"response 是 API 回傳的 JSON 資料\",\n \"extractorFormat\": \"extractor 回傳格式(所有欄位均為選填):\",\n \"tips\": \"💡 提示:\",\n \"testing\": \"測試中...\",\n \"testScript\": \"測試腳本\",\n \"format\": \"格式化\",\n \"saveConfig\": \"儲存設定\",\n \"scriptEmpty\": \"腳本設定不能為空\",\n \"mustHaveReturn\": \"腳本必須包含 return 敘述\",\n \"testSuccess\": \"測試成功!\",\n \"testFailed\": \"測試失敗\",\n \"formatSuccess\": \"格式化成功\",\n \"formatFailed\": \"格式化失敗\",\n \"supportedVariables\": \"支援的變數\",\n \"variablesHint\": \"支援變數: {{apiKey}}, {{baseUrl}} | extractor 函式接收 API 回應的 JSON 物件\",\n \"scriptConfig\": \"請求設定\",\n \"extractorCode\": \"提取器程式碼\",\n \"extractorHint\": \"回傳物件需包含剩餘額度等欄位\",\n \"fieldIsValid\": \"• isValid: 布林值,方案是否有效\",\n \"fieldInvalidMessage\": \"• invalidMessage: 字串,失效原因說明(當 isValid 為 false 時顯示)\",\n \"fieldRemaining\": \"• remaining: 數字,剩餘額度\",\n \"fieldUnit\": \"• unit: 字串,單位(如 \\\"USD\\\")\",\n \"fieldPlanName\": \"• planName: 字串,方案名稱\",\n \"fieldTotal\": \"• total: 數字,總額度\",\n \"fieldUsed\": \"• used: 數字,已用額度\",\n \"fieldExtra\": \"• extra: 字串,擴充欄位,可自由補充需要展示的文字\",\n \"tip1\": \"• 變數 {{apiKey}} 和 {{baseUrl}} 會自動替換\",\n \"tip2\": \"• extractor 函式在沙盒環境中執行,支援 ES2020+ 語法\",\n \"tip3\": \"• 整個設定必須用 () 包覆,形成物件實字運算式\"\n },\n \"dbUpgrade\": {\n \"title\": \"資料庫版本過新\",\n \"description\": \"目前資料庫由較新版本的 CC Switch 建立,需要升級應用後才能繼續使用。升級不會刪除你的資料。\",\n \"versionInfo\": \"資料庫版本 v{{db}} · 應用支援 v{{supported}}\",\n \"dbPath\": \"資料庫檔案\",\n \"checking\": \"正在檢查可用更新…\",\n \"updateAvailable\": \"發現新版本 v{{version}},升級後即可繼續使用。\",\n \"incompatibleTitle\": \"升級也無法解決\",\n \"incompatibleDescription\": \"你已是最新版本,但資料庫版本(v{{db}})仍高於本應用支援的版本(v{{supported}})。該資料庫可能由第三方用戶端或更高版本建立,升級目前官方應用也無法相容。\",\n \"preparing\": \"正在準備更新…\",\n \"downloading\": \"正在下載更新…\",\n \"upgradeNow\": \"升級應用\",\n \"retry\": \"重試升級\",\n \"openReleases\": \"開啟發佈頁\",\n \"openConfigDir\": \"開啟設定目錄\",\n \"quit\": \"結束\"\n },\n \"errors\": {\n \"usage_query_failed\": \"用量查詢失敗\",\n \"configLoadFailedTitle\": \"設定載入失敗\",\n \"configLoadFailedMessage\": \"無法讀取設定檔:\\n{{path}}\\n\\n錯誤詳情:\\n{{detail}}\\n\\n請手動檢查 JSON 是否有效,或從同目錄的備份檔案(如 config.json.bak)還原。\\n\\n應用程式將退出以便您進行修復。\"\n },\n \"presetSelector\": {\n \"title\": \"選擇設定類型\",\n \"custom\": \"自訂\",\n \"customDescription\": \"手動設定供應商,需要填寫完整的設定資訊\",\n \"officialDescription\": \"官方登入,不需要填寫 API Key\",\n \"presetDescription\": \"使用預設設定,只需填寫 API Key\"\n },\n \"mcp\": {\n \"title\": \"MCP 管理\",\n \"import\": \"匯入\",\n \"importExisting\": \"匯入已有\",\n \"addMcp\": \"新增 MCP\",\n \"claudeTitle\": \"Claude Code MCP 管理\",\n \"codexTitle\": \"Codex MCP 管理\",\n \"geminiTitle\": \"Gemini MCP 管理\",\n \"unifiedPanel\": {\n \"title\": \"MCP 伺服器管理\",\n \"addServer\": \"新增伺服器\",\n \"editServer\": \"編輯伺服器\",\n \"deleteServer\": \"刪除伺服器\",\n \"deleteConfirm\": \"確定要刪除伺服器 \\\"{{id}}\\\" 嗎?此操作無法復原。\",\n \"noServers\": \"暫無伺服器\",\n \"enabledApps\": \"啟用的應用程式\",\n \"noImportFound\": \"未發現需要匯入的 MCP 伺服器。所有伺服器已在 CC Switch 統一管理中。\",\n \"importSuccess\": \"成功匯入 {{count}} 個 MCP 伺服器\",\n \"apps\": {\n \"claude\": \"Claude\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\",\n \"openclaw\": \"OpenClaw\",\n \"hermes\": \"Hermes\"\n }\n },\n \"userLevelPath\": \"使用者級 MCP 設定路徑\",\n \"serverList\": \"伺服器清單\",\n \"loading\": \"載入中...\",\n \"empty\": \"暫無 MCP 伺服器\",\n \"emptyDescription\": \"點擊右上角按鈕新增第一個 MCP 伺服器\",\n \"add\": \"新增 MCP\",\n \"addServer\": \"新增 MCP\",\n \"editServer\": \"編輯 MCP\",\n \"addClaudeServer\": \"新增 Claude Code MCP\",\n \"editClaudeServer\": \"編輯 Claude Code MCP\",\n \"addCodexServer\": \"新增 Codex MCP\",\n \"editCodexServer\": \"編輯 Codex MCP\",\n \"configPath\": \"設定路徑\",\n \"serverCount\": \"已設定 {{count}} 個 MCP 伺服器\",\n \"enabledCount\": \"已啟用 {{count}} 個\",\n \"template\": {\n \"fetch\": \"快速範本:mcp-fetch\"\n },\n \"form\": {\n \"title\": \"MCP 標題(唯一)\",\n \"titlePlaceholder\": \"my-mcp-server\",\n \"name\": \"顯示名稱\",\n \"namePlaceholder\": \"例如 @modelcontextprotocol/server-time\",\n \"enabledApps\": \"啟用至應用程式\",\n \"noAppsWarning\": \"至少選擇一個應用程式\",\n \"description\": \"描述\",\n \"descriptionPlaceholder\": \"選填的描述資訊\",\n \"tags\": \"標籤(逗號分隔)\",\n \"tagsPlaceholder\": \"stdio, time, utility\",\n \"homepage\": \"首頁連結\",\n \"homepagePlaceholder\": \"https://example.com\",\n \"docs\": \"文件連結\",\n \"docsPlaceholder\": \"https://example.com/docs\",\n \"additionalInfo\": \"附加資訊\",\n \"jsonConfig\": \"完整的 JSON 設定\",\n \"jsonConfigOrPrefix\": \"完整的 JSON 設定或者使用\",\n \"tomlConfigOrPrefix\": \"完整的 TOML 設定或者使用\",\n \"jsonPlaceholder\": \"{\\n \\\"type\\\": \\\"stdio\\\",\\n \\\"command\\\": \\\"uvx\\\",\\n \\\"args\\\": [\\\"mcp-server-fetch\\\"]\\n}\",\n \"tomlConfig\": \"完整的 TOML 設定\",\n \"tomlPlaceholder\": \"type = \\\"stdio\\\"\\ncommand = \\\"uvx\\\"\\nargs = [\\\"mcp-server-fetch\\\"]\",\n \"useWizard\": \"設定精靈\",\n \"syncOtherSide\": \"同步至 {{target}}\",\n \"syncOtherSideHint\": \"勾選後會把目前設定同時寫入 {{target}},若存在同名設定將被覆寫\",\n \"willOverwriteWarning\": \"將覆寫 {{target}} 中的同名設定\"\n },\n \"wizard\": {\n \"title\": \"MCP 設定精靈\",\n \"hint\": \"快速設定 MCP 伺服器,自動產生 JSON 設定\",\n \"type\": \"類型\",\n \"typeStdio\": \"stdio\",\n \"typeHttp\": \"http\",\n \"typeSse\": \"sse\",\n \"command\": \"命令\",\n \"commandPlaceholder\": \"npx 或 uvx\",\n \"args\": \"參數\",\n \"argsPlaceholder\": \"arg1\\narg2\",\n \"env\": \"環境變數\",\n \"envPlaceholder\": \"KEY1=value1\\nKEY2=value2\",\n \"url\": \"URL\",\n \"urlPlaceholder\": \"https://api.example.com/mcp\",\n \"urlRequired\": \"請輸入 URL\",\n \"headers\": \"請求標頭(選填)\",\n \"headersPlaceholder\": \"Authorization: Bearer your_token_here\\nContent-Type: application/json\",\n \"preview\": \"設定預覽\",\n \"apply\": \"套用設定\"\n },\n \"id\": \"標識 (唯一)\",\n \"type\": \"類型\",\n \"command\": \"命令\",\n \"validateCommand\": \"驗證命令\",\n \"args\": \"參數\",\n \"argsPlaceholder\": \"例如:mcp-server-fetch --help\",\n \"env\": \"環境變數 (一行一個,KEY=VALUE)\",\n \"envPlaceholder\": \"FOO=bar\\nHELLO=world\",\n \"reset\": \"重設\",\n \"msg\": {\n \"saved\": \"已儲存\",\n \"deleted\": \"已刪除\",\n \"enabled\": \"已啟用\",\n \"disabled\": \"已停用\",\n \"templateAdded\": \"已新增範本\"\n },\n \"error\": {\n \"idRequired\": \"請填寫標識\",\n \"idExists\": \"該標識已存在,請更換\",\n \"jsonInvalid\": \"JSON 格式錯誤,請檢查\",\n \"tomlInvalid\": \"TOML 格式錯誤,請檢查\",\n \"commandRequired\": \"請填寫命令\",\n \"singleServerObjectRequired\": \"此處只需單個伺服器物件,請不要貼上包含 mcpServers 的整份設定\",\n \"saveFailed\": \"儲存失敗\",\n \"deleteFailed\": \"刪除失敗\"\n },\n \"validation\": {\n \"ok\": \"命令可用\",\n \"fail\": \"命令不可用\"\n },\n \"confirm\": {\n \"deleteTitle\": \"刪除 MCP 伺服器\",\n \"deleteMessage\": \"確定要刪除 MCP 伺服器 \\\"{{id}}\\\" 嗎?此操作無法復原。\"\n },\n \"presets\": {\n \"title\": \"選擇 MCP 類型\",\n \"enable\": \"啟用\",\n \"enabled\": \"已啟用\",\n \"installed\": \"已安裝\",\n \"docs\": \"文件\",\n \"requiresEnv\": \"需要環境變數\",\n \"fetch\": {\n \"name\": \"mcp-server-fetch\",\n \"description\": \"通用 HTTP 請求工具,支援 GET/POST 等 HTTP 方法,適合快速請求 API / 抓取網頁資料\"\n },\n \"time\": {\n \"name\": \"@modelcontextprotocol/server-time\",\n \"description\": \"時間查詢工具,提供目前時間、時區轉換、日期計算等功能\"\n },\n \"memory\": {\n \"name\": \"@modelcontextprotocol/server-memory\",\n \"description\": \"知識圖譜記憶系統,支援儲存實體、關係和觀察,讓 AI 記住對話中的重要資訊\"\n },\n \"sequential-thinking\": {\n \"name\": \"@modelcontextprotocol/server-sequential-thinking\",\n \"description\": \"順序思考工具,協助 AI 將複雜問題分解為多個步驟,逐步深入思考\"\n },\n \"context7\": {\n \"name\": \"@upstash/context7-mcp\",\n \"description\": \"Context7 文件搜尋工具,提供最新的函式庫文件和程式碼範例,設定 key 會有更高限額\"\n }\n }\n },\n \"prompts\": {\n \"manage\": \"提示詞\",\n \"title\": \"{{appName}} 提示詞管理\",\n \"claudeTitle\": \"Claude 提示詞管理\",\n \"codexTitle\": \"Codex 提示詞管理\",\n \"add\": \"新增提示詞\",\n \"edit\": \"編輯提示詞\",\n \"addTitle\": \"新增 {{appName}} 提示詞\",\n \"editTitle\": \"編輯 {{appName}} 提示詞\",\n \"import\": \"匯入現有\",\n \"count\": \"共 {{count}} 個提示詞\",\n \"enabled\": \"已啟用\",\n \"enable\": \"啟用\",\n \"enabledName\": \"已啟用:{{name}}\",\n \"noneEnabled\": \"未啟用任何提示詞\",\n \"currentFile\": \"目前 {{filename}} 內容\",\n \"empty\": \"暫無提示詞\",\n \"emptyDescription\": \"點擊右上角按鈕新增或匯入提示詞\",\n \"loading\": \"載入中...\",\n \"name\": \"名稱\",\n \"namePlaceholder\": \"例如:專案預設提示詞\",\n \"description\": \"描述\",\n \"descriptionPlaceholder\": \"選填的描述資訊\",\n \"content\": \"內容\",\n \"contentPlaceholder\": \"# {{filename}}\\n\\n在此輸入提示詞內容...\",\n \"loadFailed\": \"載入提示詞失敗\",\n \"saveSuccess\": \"儲存成功\",\n \"saveFailed\": \"儲存失敗\",\n \"deleteSuccess\": \"刪除成功\",\n \"deleteFailed\": \"刪除失敗\",\n \"enableSuccess\": \"啟用成功\",\n \"enableFailed\": \"啟用失敗\",\n \"disableSuccess\": \"停用成功\",\n \"disableFailed\": \"停用失敗\",\n \"importSuccess\": \"匯入成功\",\n \"importFailed\": \"匯入失敗\",\n \"confirm\": {\n \"deleteTitle\": \"確認刪除\",\n \"deleteMessage\": \"確定要刪除提示詞 \\\"{{name}}\\\" 嗎?\"\n }\n },\n \"profiles\": {\n \"switcherTooltip\": {\n \"claude\": \"專案:一鍵切換 Claude Code 的供應商 / MCP / Skills / 記憶檔案\",\n \"claude-desktop\": \"專案:一鍵切換 Claude Desktop 的供應商\",\n \"codex\": \"專案:一鍵切換 Codex 的供應商 / MCP / Skills / 記憶檔案\"\n },\n \"none\": \"不使用專案\",\n \"searchPlaceholder\": \"搜尋專案\",\n \"empty\": \"尚無專案\",\n \"createFromCurrent\": \"新建專案\",\n \"createDescription\": {\n \"claude\": \"把 Claude Code 目前的供應商、MCP、Skills、記憶檔案儲存為一個專案,之後可一鍵切換。\",\n \"claude-desktop\": \"把 Claude Desktop 目前的供應商儲存為一個專案,之後可一鍵切換。\",\n \"codex\": \"把 Codex 目前的供應商、MCP、Skills 和記憶檔案設定儲存為一個專案,之後可一鍵切換。\"\n },\n \"manage\": \"管理專案…\",\n \"manageTitle\": \"管理專案\",\n \"manageDescription\": \"專案是設定快照。切換專案時,目前設定會自動儲存回原專案。\",\n \"namePlaceholder\": \"例如:開發、繪圖\",\n \"rename\": \"重新命名\",\n \"noSnapshotForScope\": \"本側未儲存\",\n \"delete\": \"刪除\",\n \"deleteConfirmTitle\": \"確認刪除\",\n \"deleteConfirmMessage\": \"確定要刪除專案 \\\"{{name}}\\\" 嗎?不會改動任何現有設定。\",\n \"createSuccess\": \"專案已建立\",\n \"createFailed\": \"建立專案失敗: {{detail}}\",\n \"updateSuccess\": \"專案已更新\",\n \"updateFailed\": \"更新專案失敗: {{detail}}\",\n \"deleteSuccess\": \"專案已刪除\",\n \"deleteFailed\": \"刪除專案失敗: {{detail}}\",\n \"applySuccess\": \"專案已套用\",\n \"applyFailed\": \"套用專案失敗: {{detail}}\",\n \"applyWarnings\": \"專案已套用,{{warningCount}} 條警告:\\n{{details}}\",\n \"clearSuccess\": \"已切換為不使用專案\"\n },\n \"workspace\": {\n \"title\": \"Workspace 檔案管理\",\n \"manage\": \"Workspace\",\n \"files\": {\n \"agents\": \"Agent 操作指令和規則\",\n \"soul\": \"Agent 人格和溝通風格\",\n \"user\": \"使用者檔案和偏好\",\n \"identity\": \"Agent 名稱和頭像\",\n \"tools\": \"本地工具文件\",\n \"memory\": \"長期記憶和決策紀錄\",\n \"heartbeat\": \"心跳執行清單\",\n \"bootstrap\": \"首次執行儀式\",\n \"boot\": \"閘道器重新啟動清單\"\n },\n \"editing\": \"編輯 {{filename}}\",\n \"saveSuccess\": \"儲存成功\",\n \"saveFailed\": \"儲存失敗\",\n \"loadFailed\": \"讀取失敗\",\n \"openDirectory\": \"在檔案管理員中開啟\",\n \"dailyMemory\": {\n \"title\": \"每日記憶\",\n \"sectionTitle\": \"每日記憶\",\n \"cardTitle\": \"每日記憶檔案\",\n \"cardDescription\": \"瀏覽管理每日記憶\",\n \"createToday\": \"新增記憶\",\n \"empty\": \"暫無每日記憶檔案\",\n \"loadFailed\": \"載入每日記憶檔案失敗\",\n \"createFailed\": \"建立每日記憶檔案失敗\",\n \"deleteSuccess\": \"每日記憶檔案已刪除\",\n \"deleteFailed\": \"刪除每日記憶檔案失敗\",\n \"confirmDeleteTitle\": \"刪除每日記憶\",\n \"confirmDeleteMessage\": \"確定刪除 {{date}} 的每日記憶嗎?此操作不可復原。\",\n \"searchPlaceholder\": \"搜尋全文內容...\",\n \"searchScopeHint\": \"全文搜尋所有每日記憶 ⌘F\",\n \"searchCloseHint\": \"Esc 關閉\",\n \"noSearchResults\": \"沒有找到符合的每日記憶。\",\n \"searching\": \"搜尋中...\",\n \"searchFailed\": \"搜尋失敗\",\n \"matchCount\": \"{{count}} 處相符\"\n }\n },\n \"openclaw\": {\n \"backupCreated\": \"已建立備份:{{path}}\",\n \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"apiProtocol\": \"API 協定\",\n \"selectProtocol\": \"選擇 API 協定\",\n \"apiProtocolHint\": \"選擇與供應商 API 相容的協定類型。大多數供應商使用 OpenAI Completions 格式。\",\n \"baseUrl\": \"API 端點\",\n \"baseUrlHint\": \"供應商的 API 端點位址。\",\n \"models\": \"模型清單\",\n \"addModel\": \"新增模型\",\n \"noModels\": \"暫無模型設定。點擊新增模型來設定可用模型。\",\n \"modelId\": \"模型 ID\",\n \"modelIdPlaceholder\": \"claude-3-sonnet\",\n \"modelName\": \"顯示名稱\",\n \"modelNamePlaceholder\": \"Claude 3 Sonnet\",\n \"contextWindow\": \"上下文視窗\",\n \"maxTokens\": \"最大輸出 Tokens\",\n \"reasoning\": \"推理模式\",\n \"reasoningOn\": \"啟用\",\n \"reasoningOff\": \"關閉\",\n \"inputTypes\": \"輸入類型\",\n \"inputCost\": \"輸入價格 ($/M tokens)\",\n \"outputCost\": \"輸出價格 ($/M tokens)\",\n \"advancedOptions\": \"進階選項\",\n \"cacheReadCost\": \"快取讀取價格 ($/M tokens)\",\n \"cacheWriteCost\": \"快取寫入價格 ($/M tokens)\",\n \"cacheCostHint\": \"快取價格用於計算 Prompt Caching 的成本。如不使用快取可留空。\",\n \"modelsHint\": \"設定該供應商支援的模型。模型 ID 用於 API 呼叫,顯示名稱用於介面展示。\",\n \"userAgent\": \"發送 User-Agent\",\n \"userAgentHint\": \"部分供應商需要瀏覽器 User-Agent 才能正常存取。\",\n \"env\": {\n \"title\": \"環境變數\",\n \"description\": \"管理 openclaw.json 中的環境變數設定(API Key、自訂變數等)\",\n \"editorHint\": \"以 JSON 形式編輯整個 env 節點。支援 env.vars、env.shellEnv 等巢狀物件。\",\n \"objectRequired\": \"OpenClaw 的 env 必須是 JSON 物件。\",\n \"invalidJson\": \"OpenClaw 的 env 必須是合法 JSON。\",\n \"empty\": \"OpenClaw 的 env 不能為空。空物件請使用 {}。\",\n \"keyPlaceholder\": \"變數名稱\",\n \"valuePlaceholder\": \"值\",\n \"add\": \"新增變數\",\n \"saveSuccess\": \"環境變數已儲存\",\n \"saveFailed\": \"儲存環境變數失敗\",\n \"loadFailed\": \"讀取環境變數失敗\",\n \"duplicateKey\": \"檢測到重複的變數名稱: {{key}}\"\n },\n \"tools\": {\n \"title\": \"工具權限\",\n \"description\": \"管理 openclaw.json 中的工具權限設定(允許/拒絕清單)\",\n \"profile\": \"權限模式\",\n \"profileMinimal\": \"最小權限\",\n \"profileCoding\": \"編碼\",\n \"profileMessaging\": \"對話\",\n \"profileFull\": \"完全存取\",\n \"profileUnset\": \"未設定\",\n \"unsupportedProfileTitle\": \"檢測到不受支援的工具設定\",\n \"unsupportedProfileDescription\": \"目前 tools.profile 的值「{{value}}」不在 OpenClaw 支援清單內。在您手動選擇新值之前,它會被保留。\",\n \"unsupportedProfileLabel\": \"不受支援\",\n \"allowList\": \"允許清單\",\n \"denyList\": \"拒絕清單\",\n \"patternPlaceholder\": \"工具名稱或模式\",\n \"addAllow\": \"新增允許\",\n \"addDeny\": \"新增拒絕\",\n \"saveSuccess\": \"工具權限已儲存\",\n \"saveFailed\": \"儲存工具權限失敗\",\n \"loadFailed\": \"讀取工具權限失敗\"\n },\n \"agents\": {\n \"title\": \"Agents 設定\",\n \"description\": \"管理 openclaw.json 中的 agents.defaults 設定(預設模型、執行參數等)\",\n \"modelSection\": \"模型設定\",\n \"primaryModel\": \"預設模型\",\n \"primaryModelHint\": \"從已設定供應商的模型中選擇預設模型\",\n \"notSet\": \"未設定\",\n \"fallbackModels\": \"備用模型\",\n \"fallbackModelsHint\": \"當主模型不可用時,依優先順序依次嘗試以下備用模型\",\n \"addFallback\": \"新增備用模型\",\n \"noModels\": \"暫無已設定的供應商模型。請先新增 OpenClaw 供應商。\",\n \"notInList\": \"{{value}} (供應商未設定)\",\n \"runtimeSection\": \"執行參數\",\n \"workspace\": \"工作區路徑\",\n \"timeout\": \"逾時時間(秒)\",\n \"contextTokens\": \"上下文 Token 數\",\n \"maxConcurrent\": \"最大並行數\",\n \"legacyTimeoutTitle\": \"檢測到舊版逾時欄位\",\n \"legacyTimeoutDescription\": \"目前設定仍在使用 agents.defaults.timeout。儲存本頁面時會遷移為 timeoutSeconds。\",\n \"saveSuccess\": \"Agents 設定已儲存\",\n \"saveFailed\": \"儲存 Agents 設定失敗\",\n \"loadFailed\": \"讀取 Agents 設定失敗\"\n },\n \"health\": {\n \"title\": \"檢測到 OpenClaw 設定警告\",\n \"invalidToolsProfile\": \"tools.profile 使用了不受支援的值。OpenClaw 目前只支援 minimal、coding、messaging、full。\",\n \"legacyTimeout\": \"agents.defaults.timeout 已廢棄。開啟並儲存 Agents 面板即可遷移至 timeoutSeconds。\",\n \"stringifiedEnvVars\": \"env.vars 應為物件,但目前值看起來像被字串化或已損壞。\",\n \"stringifiedShellEnv\": \"env.shellEnv 應為物件,但目前值看起來像被字串化或已損壞。\",\n \"parseFailed\": \"openclaw.json 不是合法 JSON5。請先修復檔案,再透過這裡編輯。\"\n },\n \"primaryModel\": \"預設模型\",\n \"fallbackModel\": \"備用模型\"\n },\n \"hermes\": {\n \"form\": {\n \"baseUrl\": \"API 端點\",\n \"baseUrlHint\": \"供應商的 API 端點位址。\",\n \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"只能使用小寫字母、數字和連字號。用作 config.yaml 中的供應商名稱。\",\n \"providerKeyLockedHint\": \"該供應商已新增至 Hermes 設定中,標識不可修改。\",\n \"providerKeyRequired\": \"供應商標識不能為空\",\n \"providerKeyInvalid\": \"供應商標識只能包含小寫字母、數字和連字號\",\n \"providerKeyDuplicate\": \"該供應商標識已存在\",\n \"apiMode\": \"API 模式\",\n \"apiModeHint\": \"供應商 API 協定。請根據端點選擇正確的協定。\",\n \"apiModeChatCompletions\": \"OpenAI Chat Completions\",\n \"apiModeAnthropicMessages\": \"Anthropic Messages\",\n \"apiModeCodexResponses\": \"OpenAI Responses\",\n \"apiModeBedrockConverse\": \"AWS Bedrock Converse\",\n \"baseUrlRequired\": \"API 端點不能為空\",\n \"baseUrlScheme\": \"請使用 http:// 或 https:// 開頭的位址\",\n \"baseUrlInvalid\": \"API 端點不是有效的 URL\",\n \"models\": \"模型清單\",\n \"addModel\": \"新增模型\",\n \"noModels\": \"暫無模型設定。切換至此供應商時將不會更新預設模型。\",\n \"modelId\": \"模型 ID\",\n \"modelIdPlaceholder\": \"anthropic/claude-opus-4-8\",\n \"modelName\": \"顯示名稱\",\n \"modelNamePlaceholder\": \"Claude Opus 4.8\",\n \"contextLength\": \"上下文長度\",\n \"advancedOptions\": \"進階選項\",\n \"modelsHint\": \"切換至此供應商時,第一個模型會寫入頂層 model.default。\",\n \"primaryModel\": \"預設模型\",\n \"fallbackModel\": \"備用模型\",\n \"providerAdvanced\": \"供應商進階選項\",\n \"rateLimitDelay\": \"請求間隔(秒)\",\n \"rateLimitDelayHint\": \"連續請求間的最小間隔秒數(選填)。留空表示無限制。\"\n },\n \"webui\": {\n \"open\": \"開啟 Hermes Web UI\",\n \"offline\": \"Hermes Web UI 未啟動,請先執行 `hermes dashboard` 啟動服務。\",\n \"openFailed\": \"開啟 Hermes Web UI 失敗\",\n \"launchConfirmTitle\": \"Hermes Dashboard 未啟動\",\n \"launchConfirmMessage\": \"是否開啟終端機並執行 `hermes dashboard` 啟動服務?\\n\\n啟動完成後會自動開啟瀏覽器。\\n\\n若終端機提示找不到 hermes 或缺少 web 相依套件,請先執行:\\npip install hermes-agent[web]\",\n \"launchConfirmAction\": \"開啟終端機並啟動\",\n \"launching\": \"已在終端機啟動 hermes dashboard\",\n \"launchFailed\": \"開啟終端機失敗\"\n },\n \"memory\": {\n \"title\": \"記憶管理\",\n \"agentTab\": \"Agent 記憶 (MEMORY.md)\",\n \"userTab\": \"使用者畫像 (USER.md)\",\n \"usage\": \"已用 {{current}} / {{limit}} 字元\",\n \"overLimit\": \"已超過上限,Hermes 下次載入時會截斷\",\n \"enableOn\": \"已啟用\",\n \"enableOff\": \"已停用\",\n \"disabledHint\": \"在重新啟用前 Hermes 會跳過此記憶\",\n \"toggleFailed\": \"切換記憶狀態失敗\",\n \"saveSuccess\": \"記憶已儲存\",\n \"saveFailed\": \"儲存記憶失敗\",\n \"loadFailed\": \"讀取記憶檔案失敗\",\n \"openConfig\": \"在 Hermes Web UI 調整上限\",\n \"runtimeNote\": \"變更將在 Hermes 下次啟動或建立新工作階段時生效。\"\n }\n },\n \"env\": {\n \"warning\": {\n \"title\": \"檢測到系統環境變數衝突\",\n \"description\": \"發現 {{count}} 個環境變數可能會覆寫您的設定\"\n },\n \"actions\": {\n \"expand\": \"查看詳情\",\n \"collapse\": \"收起\",\n \"selectAll\": \"全選\",\n \"clearSelection\": \"取消選擇\",\n \"deleteSelected\": \"刪除選取 ({{count}})\",\n \"deleting\": \"刪除中...\"\n },\n \"field\": {\n \"value\": \"值\",\n \"source\": \"來源\"\n },\n \"source\": {\n \"userRegistry\": \"使用者環境變數 (登錄檔)\",\n \"systemRegistry\": \"系統環境變數 (登錄檔)\",\n \"systemEnv\": \"系統環境變數\"\n },\n \"delete\": {\n \"success\": \"環境變數已成功刪除\",\n \"error\": \"刪除環境變數失敗\"\n },\n \"backup\": {\n \"location\": \"備份位置:{{path}}\"\n },\n \"confirm\": {\n \"title\": \"確認刪除環境變數\",\n \"message\": \"確定要刪除 {{count}} 個環境變數嗎?\",\n \"backupNotice\": \"刪除前將自動備份,您可以稍後還原。刪除後需要重新啟動應用程式或終端機才能生效。\",\n \"confirm\": \"確認刪除\"\n },\n \"error\": {\n \"noSelection\": \"請選擇要刪除的環境變數\"\n }\n },\n \"skills\": {\n \"manage\": \"Skills\",\n \"title\": \"Skills 管理\",\n \"description\": \"從熱門的儲存庫發現並安裝技能,擴充 Claude Code/Codex/Gemini 的能力\",\n \"refresh\": \"重新整理\",\n \"refreshing\": \"重新整理中...\",\n \"repoManager\": \"儲存庫管理\",\n \"count\": \"共 {{count}} 個技能\",\n \"empty\": \"暫無可用技能\",\n \"emptyDescription\": \"新增技能儲存庫以發現可用的技能\",\n \"addRepo\": \"新增技能儲存庫\",\n \"loading\": \"載入中...\",\n \"installed\": \"已安裝\",\n \"install\": \"安裝\",\n \"installing\": \"安裝中...\",\n \"uninstall\": \"解除安裝\",\n \"uninstalling\": \"解除安裝中...\",\n \"view\": \"檢視\",\n \"noDescription\": \"暫無描述\",\n \"loadFailed\": \"載入失敗\",\n \"installSuccess\": \"技能 {{name}} 已安裝\",\n \"installFailed\": \"安裝失敗\",\n \"uninstallSuccess\": \"技能 {{name}} 已解除安裝\",\n \"uninstallFailed\": \"解除安裝失敗\",\n \"update\": \"更新\",\n \"updating\": \"更新中...\",\n \"updateAvailable\": \"可更新\",\n \"updateSuccess\": \"技能 {{name}} 已更新至最新版本\",\n \"updateFailed\": \"更新失敗\",\n \"checkUpdates\": \"檢查更新\",\n \"checkingUpdates\": \"檢查中...\",\n \"noUpdates\": \"所有技能已是最新版本\",\n \"updatesFound\": \"發現 {{count}} 個技能有可用更新\",\n \"updateAll\": \"全部更新 ({{count}})\",\n \"updatingAll\": \"更新中...\",\n \"updateAllSuccess\": \"已成功更新 {{count}} 個技能\",\n \"error\": {\n \"skillNotFound\": \"技能不存在:{{directory}}\",\n \"missingRepoInfo\": \"缺少儲存庫資訊(owner 或 name)\",\n \"downloadTimeout\": \"下載儲存庫 {{owner}}/{{name}} 逾時({{timeout}} 秒)\",\n \"downloadTimeoutHint\": \"請檢查網路連線或稍後重試\",\n \"skillPathNotFound\": \"儲存庫 {{owner}}/{{name}} 中未找到技能路徑 '{{path}}'\",\n \"skillDirNotFound\": \"技能目錄不存在:{{path}}\",\n \"directoryConflict\": \"技能目錄 '{{directory}}' 已被 {{existing_repo}} 佔用,無法從 {{new_repo}} 安裝\",\n \"emptyArchive\": \"下載的壓縮檔為空\",\n \"downloadFailed\": \"下載失敗:HTTP {{status}}\",\n \"allBranchesFailed\": \"所有分支下載失敗,嘗試了:{{branches}}\",\n \"httpError\": \"HTTP 錯誤 {{status}}\",\n \"http403\": \"GitHub 存取受限,可能是請求頻率過高\",\n \"http404\": \"儲存庫或分支不存在,請檢查位址\",\n \"http429\": \"請求過於頻繁,請等待後重試\",\n \"parseMetadataFailed\": \"解析技能中繼資料失敗\",\n \"getHomeDirFailed\": \"無法取得使用者主目錄\",\n \"noSkillsInZip\": \"ZIP 檔案中未找到技能(需包含 SKILL.md 檔案)\",\n \"networkError\": \"網路錯誤\",\n \"fsError\": \"檔案系統錯誤\",\n \"unknownError\": \"未知錯誤\",\n \"suggestion\": {\n \"checkNetwork\": \"請檢查網路連線\",\n \"checkProxy\": \"建議設定 HTTP 代理伺服器\",\n \"retryLater\": \"請稍後重試\",\n \"checkRepoUrl\": \"請檢查儲存庫位址和分支名稱\",\n \"checkDiskSpace\": \"請檢查磁碟空間\",\n \"checkPermission\": \"請檢查目錄權限\",\n \"uninstallFirst\": \"請先解除安裝已安裝的同名技能\",\n \"checkZipContent\": \"請確認 ZIP 檔案包含有效的技能目錄(含 SKILL.md 檔案)\"\n }\n },\n \"repo\": {\n \"title\": \"管理技能儲存庫\",\n \"description\": \"新增或刪除 GitHub 技能儲存庫來源\",\n \"url\": \"儲存庫 URL\",\n \"urlPlaceholder\": \"owner/name 或 https://github.com/owner/name\",\n \"branch\": \"分支\",\n \"branchPlaceholder\": \"main\",\n \"path\": \"技能路徑\",\n \"pathPlaceholder\": \"skills (選填,留空掃描根目錄)\",\n \"add\": \"新增儲存庫\",\n \"list\": \"已新增的儲存庫\",\n \"empty\": \"暫無儲存庫\",\n \"invalidUrl\": \"無效的儲存庫 URL 格式\",\n \"addSuccess\": \"儲存庫 {{owner}}/{{name}} 已新增,識別到 {{count}} 個技能\",\n \"addFailed\": \"新增失敗\",\n \"removeSuccess\": \"儲存庫 {{owner}}/{{name}} 已刪除\",\n \"removeFailed\": \"刪除失敗\",\n \"skillCount\": \"識別到 {{count}} 個技能\"\n },\n \"search\": \"搜尋技能\",\n \"searchPlaceholder\": \"搜尋技能名稱或儲存庫名稱...\",\n \"searchSource\": {\n \"repos\": \"儲存庫\",\n \"skillssh\": \"skills.sh\"\n },\n \"skillssh\": {\n \"searchPlaceholder\": \"搜尋 skills.sh(至少 2 個字元)...\",\n \"installs\": \"{{count}} 次安裝\",\n \"loadMore\": \"載入更多\",\n \"loading\": \"正在搜尋 skills.sh...\",\n \"noResults\": \"未找到 \\\"{{query}}\\\" 相關技能\",\n \"error\": \"搜尋 skills.sh 失敗\",\n \"poweredBy\": \"由 skills.sh 提供\"\n },\n \"filter\": {\n \"placeholder\": \"狀態篩選\",\n \"all\": \"全部\",\n \"installed\": \"已安裝\",\n \"uninstalled\": \"未安裝\",\n \"repo\": \"儲存庫篩選\",\n \"allRepos\": \"全部儲存庫\"\n },\n \"noResults\": \"未找到相符的技能\",\n \"noInstalled\": \"暫無已安裝的技能\",\n \"noInstalledDescription\": \"從儲存庫發現並安裝技能,或匯入已有的技能\",\n \"discover\": \"發現技能\",\n \"import\": \"匯入已有\",\n \"importDescription\": \"選擇要匯入至 CC Switch 統一管理的技能\",\n \"importSuccess\": \"成功匯入 {{count}} 個技能\",\n \"importSelected\": \"匯入已選 ({{count}})\",\n \"noUnmanagedFound\": \"未發現需要匯入的技能。所有技能已在 CC Switch 統一管理中。\",\n \"unmanagedAvailable\": \"發現可匯入的技能\",\n \"foundIn\": \"發現於\",\n \"local\": \"本地\",\n \"uninstallConfirm\": \"確定要解除安裝技能 \\\"{{name}}\\\" 嗎?這將從所有應用程式中移除該技能,並在刪除前自動建立本地備份。\",\n \"uninstallInMainPanel\": \"請在主面板中解除安裝技能\",\n \"notFound\": \"未找到技能\",\n \"backup\": {\n \"location\": \"備份位置:{{path}}\"\n },\n \"restoreFromBackup\": {\n \"button\": \"從備份中還原\",\n \"title\": \"從備份中還原\",\n \"description\": \"選擇一個 Skills 備份,將檔案還原至本地並重新加入目前清單。\",\n \"empty\": \"暫無可還原的 Skills 備份\",\n \"createdAt\": \"備份時間\",\n \"path\": \"備份路徑\",\n \"restore\": \"還原\",\n \"restoring\": \"還原中...\",\n \"delete\": \"刪除\",\n \"deleting\": \"刪除中...\",\n \"deleteSuccess\": \"技能備份 {{name}} 已刪除\",\n \"deleteFailed\": \"刪除技能備份失敗\",\n \"deleteConfirmTitle\": \"確認刪除備份\",\n \"deleteConfirmMessage\": \"確定要刪除技能備份 \\\"{{name}}\\\" 嗎?此操作無法復原。\",\n \"success\": \"技能 {{name}} 已從備份還原\",\n \"failed\": \"從備份還原失敗\"\n },\n \"apps\": {\n \"claude\": \"Claude\",\n \"codex\": \"Codex\",\n \"gemini\": \"Gemini\",\n \"opencode\": \"OpenCode\",\n \"openclaw\": \"OpenClaw\"\n },\n \"installFromZip\": {\n \"button\": \"從 ZIP 安裝\",\n \"installing\": \"安裝中...\",\n \"successSingle\": \"技能 {{name}} 已安裝\",\n \"successMultiple\": \"成功安裝 {{count}} 個技能\",\n \"noSkillsFound\": \"ZIP 檔案中未找到技能(需包含 SKILL.md 檔案)\"\n }\n },\n \"deeplink\": {\n \"confirmImport\": \"確認匯入供應商設定\",\n \"confirmImportDescription\": \"以下設定將匯入至 CC Switch\",\n \"importPrompt\": \"匯入提示詞\",\n \"importPromptDescription\": \"請確認是否匯入此系統提示詞\",\n \"importMcp\": \"匯入 MCP Servers\",\n \"importMcpDescription\": \"請確認是否匯入這些 MCP Servers\",\n \"importSkill\": \"新增 Skill 儲存庫\",\n \"importSkillDescription\": \"請確認是否新增此 Skill 儲存庫\",\n \"promptImportSuccess\": \"提示詞匯入成功\",\n \"promptImportSuccessDescription\": \"已匯入提示詞:{{name}}\",\n \"mcpImportSuccess\": \"MCP Servers 匯入成功\",\n \"mcpImportSuccessDescription\": \"成功匯入 {{count}} 個伺服器\",\n \"mcpPartialSuccess\": \"部分匯入成功\",\n \"mcpPartialSuccessDescription\": \"成功:{{success}},失敗:{{failed}}\",\n \"skillImportSuccess\": \"Skill 儲存庫新增成功\",\n \"skillImportSuccessDescription\": \"已新增儲存庫:{{repo}}\",\n \"app\": \"應用程式類型\",\n \"providerName\": \"供應商名稱\",\n \"homepage\": \"官網位址\",\n \"endpoint\": \"API 端點\",\n \"apiKey\": \"API 金鑰\",\n \"icon\": \"圖示\",\n \"model\": \"模型\",\n \"haikuModel\": \"Haiku 模型\",\n \"sonnetModel\": \"Sonnet 模型\",\n \"opusModel\": \"Opus 模型\",\n \"multiModel\": \"多模態模型\",\n \"notes\": \"備註\",\n \"import\": \"匯入\",\n \"importing\": \"匯入中...\",\n \"warning\": \"請確認以上資訊準確無誤後再匯入。匯入後可在供應商清單中編輯或刪除。\",\n \"parseError\": \"深層連結解析失敗\",\n \"importSuccess\": \"匯入成功\",\n \"importSuccessDescription\": \"供應商 \\\"{{name}}\\\" 已成功匯入\",\n \"importError\": \"匯入失敗\",\n \"configSource\": \"設定來源\",\n \"configEmbedded\": \"內嵌設定\",\n \"configRemote\": \"遠端設定\",\n \"configDetails\": \"設定詳情\",\n \"configUrl\": \"設定檔 URL\",\n \"configMergeError\": \"合併設定檔失敗\",\n \"primaryEndpoint\": \"主\",\n \"mcp\": {\n \"title\": \"批次匯入 MCP Servers\",\n \"targetApps\": \"目標應用程式\",\n \"serverCount\": \"MCP Servers ({{count}} 個)\",\n \"enabledWarning\": \"匯入後將立即寫入所有指定應用程式的設定檔\"\n },\n \"prompt\": {\n \"title\": \"匯入系統提示詞\",\n \"app\": \"應用程式\",\n \"name\": \"名稱\",\n \"description\": \"描述\",\n \"contentPreview\": \"內容預覽\",\n \"enabledWarning\": \"匯入後將立即啟用此提示詞,其他提示詞將被停用\"\n },\n \"skill\": {\n \"title\": \"新增 Claude Skill 儲存庫\",\n \"repo\": \"GitHub 儲存庫\",\n \"directory\": \"目標目錄\",\n \"branch\": \"分支\",\n \"skillsPath\": \"Skills 路徑\",\n \"hint\": \"此操作將新增 Skill 儲存庫至清單。\",\n \"hintDetail\": \"新增後,您可以在 Skills 管理介面中選擇安裝具體的 Skill。\"\n },\n \"usageScript\": \"用量查詢\",\n \"usageScriptEnabled\": \"已啟用\",\n \"usageScriptDisabled\": \"未啟用\",\n \"usageApiKey\": \"用量 API Key\",\n \"usageBaseUrl\": \"用量查詢位址\",\n \"usageAutoInterval\": \"自動查詢\",\n \"usageAutoIntervalValue\": \"每 {{minutes}} 分鐘\"\n },\n \"iconPicker\": {\n \"search\": \"搜尋圖示\",\n \"searchPlaceholder\": \"輸入圖示名稱...\",\n \"noResults\": \"未找到相符的圖示\",\n \"category\": {\n \"aiProvider\": \"AI 服務商\",\n \"cloud\": \"雲端平台\",\n \"tool\": \"開發工具\",\n \"other\": \"其他\"\n }\n },\n \"providerIcon\": {\n \"label\": \"圖示\",\n \"colorLabel\": \"圖示顏色\",\n \"selectIcon\": \"選擇圖示\",\n \"preview\": \"預覽\",\n \"clickToChange\": \"點擊更換圖示\",\n \"clickToSelect\": \"點擊選擇圖示\",\n \"color\": \"圖示顏色\"\n },\n \"migration\": {\n \"success\": \"設定遷移成功\",\n \"skillsSuccess\": \"已自動匯入 {{count}} 個技能至統一管理\",\n \"skillsFailed\": \"自動匯入技能失敗\",\n \"skillsFailedDescription\": \"請開啟 Skills 頁面點擊「匯入現有」手動匯入(或重新啟動後再試)。\"\n },\n \"agents\": {\n \"title\": \"Agent\"\n },\n \"health\": {\n \"operational\": \"正常\",\n \"degraded\": \"降級\",\n \"failed\": \"失敗\",\n \"circuitOpen\": \"斷路\",\n \"consecutiveFailures\": \"連續失敗 {{count}} 次\"\n },\n \"failover\": {\n \"enabled\": \"{{app}} 故障轉移已啟用\",\n \"disabled\": \"{{app}} 故障轉移已關閉\",\n \"toggleFailed\": \"操作失敗:{{detail}}\",\n \"inQueue\": \"已加入\",\n \"addQueue\": \"加入\",\n \"priority\": {\n \"tooltip\": \"故障轉移優先順序 {{priority}}\"\n },\n \"tooltip\": {\n \"enabled\": \"{{app}} 故障轉移已啟用\\n按佇列優先順序(P1→P2→...)選擇供應商\",\n \"disabled\": \"啟用 {{app}} 故障轉移\\n將立即切換至佇列 P1,並在失敗時自動切換至下一個\"\n }\n },\n \"proxy\": {\n \"panel\": {\n \"serviceAddress\": \"服務位址\",\n \"addressCopied\": \"位址已複製\",\n \"currentProvider\": \"目前 Provider:\",\n \"waitingFirstRequest\": \"目前 Provider:等待首次請求...\",\n \"stoppedTitle\": \"路由服務已停止\",\n \"stoppedDescription\": \"使用上方開關即可啟動服務\",\n \"openSettings\": \"設定路由服務\",\n \"stats\": {\n \"activeConnections\": \"活躍連線\",\n \"totalRequests\": \"總請求數\",\n \"successRate\": \"成功率\",\n \"uptime\": \"運作時間\"\n }\n },\n \"settings\": {\n \"title\": \"路由服務設定\",\n \"description\": \"設定本地路由伺服器的監聽位址、連接埠和執行參數,儲存後立即生效。\",\n \"alert\": {\n \"autoApply\": \"儲存後將自動同步至正在執行的路由服務,無需手動重新啟動。\"\n },\n \"basic\": {\n \"title\": \"基礎設定\",\n \"description\": \"設定路由服務監聽的位址與連接埠。\"\n },\n \"advanced\": {\n \"title\": \"進階參數\",\n \"description\": \"控制請求的穩定性和日誌紀錄。\"\n },\n \"timeout\": {\n \"title\": \"逾時設定\",\n \"description\": \"設定串流和非串流請求的逾時時間。\"\n },\n \"fields\": {\n \"listenAddress\": {\n \"label\": \"監聽位址\",\n \"placeholder\": \"127.0.0.1\",\n \"description\": \"路由伺服器監聽的 IP 位址(推薦 127.0.0.1)\"\n },\n \"listenPort\": {\n \"label\": \"監聽連接埠\",\n \"placeholder\": \"15721\",\n \"description\": \"路由伺服器監聽的連接埠號(1024 ~ 65535)\"\n },\n \"maxRetries\": {\n \"label\": \"最大重試次數\",\n \"placeholder\": \"3\",\n \"description\": \"請求失敗時的重試次數(0 ~ 10)\"\n },\n \"requestTimeout\": {\n \"label\": \"請求逾時(秒)\",\n \"placeholder\": \"0(不限)或 300\",\n \"description\": \"單個請求的最大等待時間(0 表示不限制,或設定 10 ~ 600 秒)\"\n },\n \"enableLogging\": {\n \"label\": \"啟用日誌紀錄\",\n \"description\": \"紀錄所有路由請求,便於排查問題\"\n },\n \"streamingFirstByteTimeout\": {\n \"label\": \"串流首位元組逾時(秒)\",\n \"description\": \"等待首個資料區塊的最大時間\"\n },\n \"streamingIdleTimeout\": {\n \"label\": \"串流閒置逾時(秒)\",\n \"description\": \"資料區塊之間的最大間隔\"\n },\n \"nonStreamingTimeout\": {\n \"label\": \"非串流逾時(秒)\",\n \"description\": \"非串流請求的總逾時時間\"\n }\n },\n \"validation\": {\n \"addressInvalid\": \"請輸入有效的 IP 位址\",\n \"portMin\": \"連接埠必須大於 1024\",\n \"portMax\": \"連接埠必須小於 65535\",\n \"retryMin\": \"重試次數不能為負\",\n \"retryMax\": \"重試次數不能超過 10\",\n \"timeoutNonNegative\": \"逾時時間不能為負數\",\n \"timeoutMax\": \"逾時時間最多 600 秒\",\n \"timeoutRange\": \"請輸入 0 或 10-600 之間的數值\",\n \"streamingTimeoutMin\": \"逾時時間至少 5 秒\",\n \"streamingTimeoutMax\": \"逾時時間最多 300 秒\"\n },\n \"actions\": {\n \"save\": \"儲存設定\"\n },\n \"toast\": {\n \"saved\": \"路由設定已儲存\",\n \"saveFailed\": \"儲存失敗:{{error}}\"\n },\n \"invalidPort\": \"連接埠無效,請輸入 1024-65535 之間的數字\",\n \"invalidAddress\": \"位址無效,請輸入 IPv4(如 127.0.0.1)、IPv6(如 ::1)或 localhost\",\n \"configSaved\": \"路由設定已儲存\",\n \"configSaveFailed\": \"儲存設定失敗\",\n \"restartRequired\": \"修改位址或連接埠後需要重新啟動路由服務才能生效\"\n },\n \"switchFailed\": \"切換失敗:{{error}}\",\n \"takeover\": {\n \"hint\": \"選擇要路由的應用程式,啟用後該應用程式的請求將透過本地路由轉發\",\n \"enabled\": \"{{app}} 路由已啟用\",\n \"disabled\": \"{{app}} 路由已關閉\",\n \"failed\": \"切換路由狀態失敗:{{detail}}\",\n \"tooltip\": {\n \"active\": \"{{appLabel}} 路由中 - {{address}}:{{port}}\\n切換該應用程式供應商為熱切換\",\n \"broken\": \"{{appLabel}} 路由中,但路由服務未執行\",\n \"inactive\": \"路由 {{appLabel}} 的請求,讓該應用程式請求走本地路由\"\n }\n },\n \"failover\": {\n \"proxyRequired\": \"需要先啟動路由服務才能設定故障轉移\",\n \"autoSwitch\": \"自動故障轉移\",\n \"autoSwitchDescription\": \"開啟後將立即切換至佇列 P1,並在請求失敗時自動切換至佇列中的下一個供應商\"\n },\n \"failoverQueue\": {\n \"title\": \"故障轉移佇列\",\n \"description\": \"管理各應用程式的供應商故障轉移順序\",\n \"info\": \"啟用自動故障轉移後,將按佇列優先順序選擇供應商(P1 優先)。當請求失敗時,系統會按佇列順序依次嘗試下一個供應商。\",\n \"selectProvider\": \"選擇供應商新增至佇列\",\n \"noAvailableProviders\": \"沒有可新增的供應商\",\n \"empty\": \"故障轉移佇列為空。新增供應商以啟用自動故障轉移。\",\n \"orderHint\": \"佇列順序與首頁供應商清單順序一致,可在首頁拖曳調整順序。\",\n \"dragHint\": \"拖曳供應商可調整故障轉移順序,序號越小優先順序越高。\",\n \"toggleEnabled\": \"啟用/停用\",\n \"addSuccess\": \"已新增至故障轉移佇列\",\n \"addFailed\": \"新增失敗\",\n \"removeSuccess\": \"已從故障轉移佇列移除\",\n \"removeFailed\": \"移除失敗\",\n \"reorderSuccess\": \"佇列順序已更新\",\n \"reorderFailed\": \"更新順序失敗\",\n \"toggleFailed\": \"狀態更新失敗\"\n },\n \"autoFailover\": {\n \"info\": \"當故障轉移佇列中設定了多個供應商時,系統會在請求失敗時按優先順序依次嘗試。當某個供應商連續失敗達到閾值時,斷路器會打開並在一段時間內跳過該供應商。\",\n \"configSaved\": \"自動故障轉移設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"validationFailed\": \"以下欄位超出有效範圍:{{fields}}\",\n \"retrySettings\": \"重試與逾時設定\",\n \"failureThreshold\": \"失敗閾值\",\n \"failureThresholdHint\": \"連續失敗多少次後打開斷路器(建議:3-10)\",\n \"timeout\": \"恢復等待時間(秒)\",\n \"timeoutHint\": \"斷路器打開後,等待多久後嘗試恢復(建議:30-120)\",\n \"circuitBreakerSettings\": \"斷路器設定\",\n \"successThreshold\": \"恢復成功閾值\",\n \"successThresholdHint\": \"半開狀態下成功多少次後關閉斷路器\",\n \"errorRate\": \"錯誤率閾值 (%)\",\n \"errorRateHint\": \"錯誤率超過此值時打開斷路器\",\n \"minRequests\": \"最小請求數\",\n \"minRequestsHint\": \"計算錯誤率前的最小請求數\",\n \"explanationTitle\": \"運作原理\",\n \"failureThresholdLabel\": \"失敗閾值\",\n \"failureThresholdExplain\": \"連續失敗達到此次數時,斷路器打開,該供應商暫時不可用\",\n \"timeoutLabel\": \"恢復等待時間\",\n \"timeoutExplain\": \"斷路器打開後,等待此時間後嘗試半開狀態\",\n \"successThresholdLabel\": \"恢復成功閾值\",\n \"successThresholdExplain\": \"半開狀態下,成功達到此次數時關閉斷路器,供應商恢復可用\",\n \"errorRateLabel\": \"錯誤率閾值\",\n \"errorRateExplain\": \"錯誤率超過此值時,即使未達到失敗閾值也會打開斷路器\",\n \"maxRetries\": \"最大重試次數\",\n \"timeoutSettings\": \"逾時設定\",\n \"streamingFirstByte\": \"串流首位元組逾時\",\n \"streamingIdle\": \"串流閒置逾時\",\n \"nonStreaming\": \"非串流逾時\",\n \"maxRetriesHint\": \"請求失敗時的重試次數(0-10)\",\n \"streamingFirstByteHint\": \"等待首個資料區塊的最大時間,範圍 1-120 秒,預設 60 秒\",\n \"streamingIdleHint\": \"資料區塊之間的最大間隔,範圍 60-600 秒,填 0 停用(防止中途卡住)\",\n \"nonStreamingHint\": \"非串流請求的總逾時時間,範圍 60-1200 秒,預設 600 秒(10 分鐘)\"\n },\n \"logging\": {\n \"enabled\": \"日誌紀錄已啟用\",\n \"disabled\": \"日誌紀錄已關閉\",\n \"failed\": \"切換日誌狀態失敗\"\n },\n \"server\": {\n \"started\": \"路由服務已啟動 - {{address}}:{{port}}\",\n \"startFailed\": \"啟動路由服務失敗:{{detail}}\"\n },\n \"stoppedWithRestore\": \"路由服務已關閉,已還原所有路由設定\",\n \"stopWithRestoreFailed\": \"停止失敗:{{detail}}\"\n },\n \"streamCheck\": {\n \"reachable\": \"{{providerName}} 連通正常 ({{responseTimeMs}}ms)\",\n \"reachableSlow\": \"{{providerName}} 連通但較慢 ({{responseTimeMs}}ms)\",\n \"unreachable\": \"{{providerName}} 無法連通: {{message}}\",\n \"unreachableHint\": \"無法建立連線(DNS / 連線 / TLS / 逾時)。請檢查 base_url 與網路。\",\n \"connectivityNote\": \"連通檢測僅探測供應商位址是否可達,不發送真實模型請求。收到任意回應即視為「可達」——這不代表驗證或模型設定一定正確。\",\n \"configSaved\": \"連通檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"緩慢閾值(毫秒)\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\"\n },\n \"proxyConfig\": {\n \"proxyEnabled\": \"路由總開關\",\n \"appTakeover\": \"路由啟用\",\n \"perAppConfig\": \"應用程式設定\",\n \"circuitBreaker\": \"斷路器設定\",\n \"circuitBreakerSettings\": \"斷路器設定\",\n \"failureThreshold\": \"失敗閾值\",\n \"successThreshold\": \"恢復閾值\",\n \"recoveryTimeout\": \"恢復等待時間\",\n \"errorRateThreshold\": \"錯誤率閾值\",\n \"minRequests\": \"最小請求數\",\n \"timeoutConfig\": \"逾時設定\",\n \"streamingFirstByte\": \"串流首位元組逾時\",\n \"streamingIdle\": \"串流閒置逾時\",\n \"nonStreaming\": \"非串流逾時\"\n },\n \"circuitBreaker\": {\n \"failureThreshold\": \"失敗閾值\",\n \"successThreshold\": \"成功閾值\",\n \"timeoutSeconds\": \"逾時時間(秒)\",\n \"errorRateThreshold\": \"錯誤率閾值 (%)\",\n \"minRequests\": \"最小請求數\",\n \"validationFailed\": \"以下欄位超出有效範圍:{{fields}}\",\n \"configSaved\": \"斷路器設定已儲存\",\n \"saveFailed\": \"儲存失敗\",\n \"loading\": \"載入中...\",\n \"title\": \"斷路器設定\",\n \"description\": \"調整斷路器參數以控制故障檢測和恢復行為\",\n \"failureThresholdHint\": \"連續失敗多少次後打開斷路器\",\n \"timeoutSecondsHint\": \"斷路器打開後多久嘗試恢復(半開狀態)\",\n \"successThresholdHint\": \"半開狀態下成功多少次後關閉斷路器\",\n \"errorRateThresholdHint\": \"錯誤率超過此值時打開斷路器\",\n \"minRequestsHint\": \"計算錯誤率前的最小請求數\",\n \"saveConfig\": \"儲存設定\",\n \"instructionsTitle\": \"設定說明\",\n \"instructions\": {\n \"failureThreshold\": \"連續失敗達到此次數時,斷路器打開\",\n \"timeout\": \"斷路器打開後,等待此時間後嘗試半開\",\n \"successThreshold\": \"半開狀態下,成功達到此次數時關閉斷路器\",\n \"errorRate\": \"錯誤率超過此值時,斷路器打開\",\n \"minRequests\": \"只有請求數達到此值後才計算錯誤率\"\n }\n },\n \"universalProvider\": {\n \"duplicate\": \"複製\",\n \"duplicatedAndSynced\": \"通用供應商已複製並同步\",\n \"duplicateError\": \"複製通用供應商失敗\",\n \"title\": \"通用供應商\",\n \"description\": \"通用供應商可以同時管理 Claude、Codex 和 Gemini 的設定。修改後會自動同步至所有啟用的應用程式。\",\n \"add\": \"新增通用供應商\",\n \"edit\": \"編輯通用供應商\",\n \"empty\": \"還沒有通用供應商\",\n \"emptyHint\": \"點擊下方「新增通用供應商」按鈕建立一個\",\n \"selectPreset\": \"選擇預設類型\",\n \"name\": \"名稱\",\n \"namePlaceholder\": \"例如:我的 NewAPI\",\n \"baseUrl\": \"API 位址\",\n \"apiKey\": \"API Key\",\n \"websiteUrl\": \"官網位址\",\n \"websiteUrlPlaceholder\": \"https://example.com(選填,用於在清單中顯示)\",\n \"notes\": \"備註\",\n \"notesPlaceholder\": \"選填:加入備註資訊\",\n \"enabledApps\": \"啟用的應用程式\",\n \"modelConfig\": \"模型設定\",\n \"model\": \"模型\",\n \"sync\": \"同步至應用程式\",\n \"synced\": \"已同步至所有應用程式\",\n \"syncError\": \"同步失敗\",\n \"noAppsEnabled\": \"未啟用任何應用程式\",\n \"added\": \"通用供應商已新增\",\n \"addedAndSynced\": \"通用供應商已新增並同步\",\n \"addedButSyncFailed\": \"通用供應商已新增,但同步失敗\",\n \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addFailed\": \"通用供應商新增失敗\",\n \"hint\": \"跨應用程式通用設定,自動同步至 Claude/Codex/Gemini\",\n \"manage\": \"管理\",\n \"loadError\": \"載入通用供應商失敗\",\n \"saveError\": \"儲存通用供應商失敗\",\n \"deleteError\": \"刪除通用供應商失敗\",\n \"deleteConfirmTitle\": \"刪除通用供應商\",\n \"deleteConfirmDescription\": \"確定要刪除 \\\"{{name}}\\\" 嗎?這將同時刪除它在各應用程式中產生的供應商設定。\",\n \"syncConfirmTitle\": \"同步通用供應商\",\n \"syncConfirmDescription\": \"同步 \\\"{{name}}\\\" 將會覆寫 Claude、Codex 和 Gemini 中關聯的供應商設定。確定要繼續嗎?\",\n \"syncConfirm\": \"同步\",\n \"saveAndSync\": \"儲存並同步\",\n \"savedAndSynced\": \"已儲存並同步至所有應用程式\",\n \"saveAndSyncError\": \"儲存並同步失敗\",\n \"configJsonPreview\": \"設定 JSON 預覽\",\n \"configJsonPreviewHint\": \"以下是將要同步至各應用程式的設定內容(僅覆寫顯示的欄位,保留其他自訂設定)\"\n },\n \"omo\": {\n \"editProfile\": \"編輯 OMO 設定\",\n \"newProfile\": \"新增 OMO 設定\",\n \"profileName\": \"名稱\",\n \"mainAgents\": \"主 Agent\",\n \"subAgents\": \"子 Agent\",\n \"categories\": \"分類\",\n \"customAgents\": \"自訂 Agent\",\n \"noCustomAgents\": \"暫無自訂 Agent\",\n \"otherFields\": \"其他設定\",\n \"globalConfig\": \"OMO 全域設定\",\n \"globalConfigShort\": \"OMO 設定\",\n \"globalConfigSaved\": \"全域設定已儲存\",\n \"addProfile\": \"新增 OMO 設定\",\n \"disabledItems\": \"停用項目設定\",\n \"advanced\": \"進階設定\",\n \"profileCreated\": \"OMO 設定已建立\",\n \"profileUpdated\": \"OMO 設定已更新\",\n \"invalidJson\": \"其他欄位包含無效 JSON\",\n \"confirmDelete\": \"刪除設定\",\n \"confirmDeleteMsg\": \"確定刪除 \\\"{{name}}\\\" 嗎?\",\n \"profileDeleted\": \"設定已刪除\",\n \"imported\": \"已匯入為 \\\"{{name}}\\\"\",\n \"import\": \"匯入\",\n \"global\": \"全域\",\n \"empty\": \"暫無設定。點擊 + 新增或從本地匯入。\",\n \"applied\": \"已套用\",\n \"apply\": \"套用\",\n \"enable\": \"啟用\",\n \"enabled\": \"啟用中\",\n \"disabled\": \"OMO 已停用\",\n \"disableFailed\": \"停用 OMO 失敗:{{error}}\",\n \"selectPlaceholder\": \"請選擇...\",\n \"clear\": \"清空\",\n \"clearWrapped\": \"(清空)\",\n \"defaultWrapped\": \"(預設)\",\n \"variantPlaceholder\": \"思考等級\",\n \"selectEnabledModel\": \"選擇已設定模型\",\n \"selectModel\": \"選擇已設定模型\",\n \"recommendedHint\": \"推薦:{{model}}\",\n \"searchModel\": \"搜尋模型...\",\n \"selectModelFirst\": \"先選擇模型\",\n \"noEnabledModels\": \"暫無已設定模型\",\n \"noVariantsForModel\": \"該模型無思考等級\",\n \"currentValueNotEnabled\": \"{{value}}(目前值,未設定)\",\n \"currentValueUnavailable\": \"{{value}}(目前值,不可用)\",\n \"advancedLabel\": \"進階參數\",\n \"advancedJsonInvalid\": \"進階參數 JSON 無效\",\n \"advancedJsonHint\": \"temperature, top_p, budgetTokens, prompt_append, permission 等,留空使用預設值\",\n \"noEnabledModelsWarning\": \"目前沒有可用的已設定模型,請先設定 OpenCode 模型\",\n \"modelSourcePartialWarning\": \"部分供應商模型設定無效,已自動跳過。\",\n \"modelSourceFallbackWarning\": \"讀取 live 供應商狀態失敗,已回退至已設定供應商清單。\",\n \"importLocalReplaceSuccess\": \"已從本地檔案匯入並覆寫 Agent/Category/Other Fields\",\n \"importLocalFailed\": \"讀取本地檔案失敗:{{error}}\",\n \"agentKeyPlaceholder\": \"agent 鍵名\",\n \"categoryKeyPlaceholder\": \"分類鍵名\",\n \"modelNamePlaceholder\": \"模型名\",\n \"custom\": \"自訂\",\n \"customCategories\": \"自訂分類\",\n \"modelConfiguration\": \"模型設定\",\n \"fillRecommended\": \"填入推薦\",\n \"fillRecommendedSuccess\": \"已填入 {{count}} 個推薦模型\",\n \"fillRecommendedPartial\": \"已填入 {{filled}} 個推薦模型,{{unmatched}} 個未比對成功\",\n \"fillRecommendedMostlyUnmatched\": \"僅填入 {{filled}} 個,{{unmatched}} 個未比對成功(如 {{examples}})。請設定提供這些模型的供應商,或手動選擇替代項。\",\n \"fillRecommendedAllSet\": \"所有槽位已有模型設定\",\n \"fillRecommendedNoMatch\": \"推薦模型在已設定的供應商中未找到相符項目(如 {{examples}})\",\n \"configSummary\": \"已設定 {{agents}} 個 Agent,{{categories}} 個 Category · 點擊 ⚙ 展開進階參數\",\n \"enabledModelsCount\": \"可選已設定模型 {{count}} 個\",\n \"source\": \"來源:\",\n \"otherFieldsJson\": \"其他欄位 (JSON)\",\n \"slimOtherFieldsHint\": \"OMO Slim 的 council、fallback、multiplexer、disabled_mcps、todoContinuation 等頂層設定請寫在這裡。\",\n \"searchOrType\": \"搜尋或輸入自訂值...\",\n \"noMatches\": \"無相符項目\",\n \"jsonMustBeObject\": \"{{field}} 必須是 JSON 物件\",\n \"jsonInvalid\": \"{{field}} 包含無效 JSON\",\n \"importGlobalSuccess\": \"已從本地檔案匯入全域設定(未儲存)\",\n \"importGlobalFailed\": \"讀取本地檔案失敗:{{error}}\",\n \"importLocal\": \"從本地匯入\",\n \"saveGlobalConfig\": \"儲存全域設定\",\n \"schemaUrl\": \"$schema\",\n \"resetDefault\": \"還原預設\",\n \"sisyphusAgentConfig\": \"Sisyphus Agent 設定\",\n \"disabledAgents\": \"Agents\",\n \"disabledAgentsPlaceholder\": \"停用的 Agents\",\n \"disabledMcps\": \"MCPs\",\n \"disabledMcpsPlaceholder\": \"停用的 MCPs\",\n \"disabledHooks\": \"Hooks\",\n \"disabledHooksPlaceholder\": \"停用的 Hooks\",\n \"disabledSkills\": \"Skills\",\n \"disabledSkillsPlaceholder\": \"停用的 Skills\",\n \"advancedLsp\": \"LSP 設定\",\n \"advancedExperimental\": \"實驗性功能\",\n \"advancedBackgroundTask\": \"背景任務\",\n \"advancedBrowserAutomation\": \"瀏覽器自動化\",\n \"advancedClaudeCode\": \"Claude Code\",\n \"agentDesc\": {\n \"sisyphus\": \"主協調器\",\n \"hephaestus\": \"自主深度工作者\",\n \"prometheus\": \"戰略規劃者\",\n \"atlas\": \"任務管理者\",\n \"oracle\": \"戰略顧問\",\n \"librarian\": \"多儲存庫研究員\",\n \"explore\": \"快速程式碼搜尋\",\n \"multimodalLooker\": \"媒體分析器\",\n \"metis\": \"規劃前分析顧問\",\n \"momus\": \"計畫審查者\",\n \"sisyphusJunior\": \"委派任務執行器\"\n },\n \"agentTooltip\": {\n \"sisyphus\": \"主協調器,負責任務規劃、委派與並行執行,使用擴充思考(32k 預算),透過 TODO 驅動工作流程確保任務完成。\",\n \"atlas\": \"主協調器(持有 TODO 清單),負責執行階段的任務分發與協調,不直接完成所有工作而是委派給專業代理。\",\n \"prometheus\": \"戰略規劃師,透過訪談模式收集需求並制定詳細工作計畫,僅能在 .sisyphus/ 目錄讀寫 Markdown 檔案,從不直接寫程式碼。\",\n \"hephaestus\": \"自主深度工作者(「合法工匠」),受 AmpCode 深度模式啟發,目標導向執行,行動前會並行啟動 2-5 個探索 / 圖書管理員代理進行研究。\",\n \"oracle\": \"架構決策與除錯顧問,唯讀諮詢代理,提供出色的邏輯推理和深度分析,不能寫檔案或委派任務。\",\n \"librarian\": \"多儲存庫分析與文件檢索專家,深度理解程式碼庫並提供基於證據的答案,擅長尋找官方文件和開源實作範例。\",\n \"explore\": \"快速程式碼庫探索與上下文 grep 專家,使用輕量級模型進行高速搜尋,是理解程式碼結構的先鋒。\",\n \"multimodalLooker\": \"視覺內容專家,分析 PDF、圖片、圖表等非文字媒體,提取其中的資訊與洞見。\",\n \"metis\": \"計畫顧問,在規劃前進行預先分析,識別隱藏意圖、模糊點和 AI 失敗點,防止過度工程化。\",\n \"momus\": \"計畫審查員,高精度驗證計畫的清晰度、可驗證性和完整性,拒絕並要求修訂直到計畫完美。\",\n \"sisyphusJunior\": \"類別產生的執行器,由 category 參數自動產生,專注於執行分配的任務且不能再委派,防止無限委派迴圈。\"\n },\n \"categoryDesc\": {\n \"visualEngineering\": \"視覺 / 前端工程\",\n \"ultrabrain\": \"超級思考\",\n \"deep\": \"深度工作\",\n \"artistry\": \"創意 / 文藝\",\n \"quick\": \"快速回應\",\n \"unspecifiedLow\": \"通用低階\",\n \"unspecifiedHigh\": \"通用高階\",\n \"writing\": \"寫作\"\n },\n \"categoryTooltip\": {\n \"visualEngineering\": \"前端與視覺工程類別,專注於 UI/UX 設計、樣式、動畫和介面實作,預設使用 Gemini 3.5 Flash 模型。\",\n \"ultrabrain\": \"深度邏輯推理類別,用於需要廣泛分析的複雜架構決策,預設使用 GPT-5.3 Codex 的超高推理變體。\",\n \"deep\": \"深度自主問題解決類別,目標導向執行,行動前進行徹底研究,適用於需要深度理解的棘手問題。\",\n \"artistry\": \"高度創意與藝術性任務類別,激發新穎想法和創造性解決方案,預設使用 Gemini 3.5 Flash 的高推理變體。\",\n \"quick\": \"輕量任務類別,用於單檔案修改、錯別字修復、簡單調整等瑣碎工作,預設使用 Claude Haiku 4.5 快速模型。\",\n \"unspecifiedLow\": \"未分類低工作量任務類別,適用於不適合其他類別且工作量較小的任務,預設使用 Claude Sonnet 4.5。\",\n \"unspecifiedHigh\": \"未分類高工作量任務類別,適用於不適合其他類別且工作量較大的任務,預設使用 Claude Opus 4.8 的最大變體。\",\n \"writing\": \"寫作類別,專注於文件、散文和技術寫作,預設使用 Kimi K2.5 模型。\"\n },\n \"slimAgentDesc\": {\n \"orchestrator\": \"協調器\",\n \"oracle\": \"神諭者\",\n \"librarian\": \"圖書管理員\",\n \"explorer\": \"探索者\",\n \"designer\": \"設計師\",\n \"fixer\": \"修復者\",\n \"council\": \"議會\"\n },\n \"slimAgentTooltip\": {\n \"orchestrator\": \"編寫執行程式碼,協調多代理工作流程,召喚專家\",\n \"oracle\": \"根本原因分析、架構審查、除錯指導(唯讀)\",\n \"librarian\": \"文件查詢、GitHub 程式碼搜尋(唯讀)\",\n \"explorer\": \"正規搜尋、AST 模式比對、檔案發現(唯讀)\",\n \"designer\": \"現代響應式設計、CSS / Tailwind 精通\",\n \"fixer\": \"程式碼實作、重構、測試、驗證\",\n \"council\": \"多模型共識代理,並行執行多個唯讀 councillor,再由 master 彙整最終答案。\"\n }\n },\n \"openclawConfig\": {\n \"defaultModel\": {\n \"title\": \"預設模型\",\n \"description\": \"設定 OpenClaw 的預設主模型和備用模型\",\n \"primary\": \"主模型\",\n \"primaryPlaceholder\": \"例如: deepseek/deepseek-chat\",\n \"fallbacks\": \"備用模型\",\n \"fallbacksPlaceholder\": \"例如: openrouter/anthropic/claude-sonnet-4.5\",\n \"addFallback\": \"新增備用模型\",\n \"saved\": \"預設模型設定已儲存\",\n \"saveFailed\": \"儲存預設模型失敗\"\n },\n \"modelCatalog\": {\n \"title\": \"模型目錄\",\n \"description\": \"設定可用模型的別名和允許清單\",\n \"modelId\": \"模型 ID\",\n \"modelIdPlaceholder\": \"例如: deepseek/deepseek-chat\",\n \"alias\": \"別名\",\n \"aliasPlaceholder\": \"例如: DeepSeek\",\n \"addEntry\": \"新增模型\",\n \"removeEntry\": \"移除\",\n \"saved\": \"模型目錄已儲存\",\n \"saveFailed\": \"儲存模型目錄失敗\",\n \"empty\": \"暫無模型目錄設定\",\n \"emptyHint\": \"新增模型至目錄以設定別名\"\n },\n \"suggestedDefaults\": \"套用建議預設設定\",\n \"suggestedDefaultsHint\": \"使用此預設推薦的預設模型設定\"\n },\n \"subscription\": {\n \"title\": \"訂閱額度\",\n \"fiveHour\": \"5 小時\",\n \"sevenDay\": \"7 天\",\n \"sevenDayOpus\": \"7 天 (Opus)\",\n \"sevenDaySonnet\": \"7 天 (Sonnet)\",\n \"thirtyDay\": \"30 天\",\n \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"monthly\": \"每月\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}}, "src-tauri/tests/provider_service.rs": {"1e398a3709f03db3fe5d989827090386b9dcd3be": {"t": 1785746253.1007273, "n": 98652, "h": "bbbb0a8899813ee2", "c": "use serde_json::json;\n\nuse cc_switch_lib::{\n get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType, McpApps,\n McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,\n};\n\n#[path = \"support.rs\"]\nmod support;\nuse support::{\n create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,\n ensure_test_home, reset_test_fs, test_mutex,\n};\n\nfn sanitize_provider_name(name: &str) -> String {\n name.chars()\n .map(|c| match c {\n '<' | '>' | ':' | '\"' | '/' | '\\\\' | '|' | '?' | '*' => '-',\n _ => c,\n })\n .collect::<String>()\n .to_lowercase()\n}\n\n#[test]\nfn migrate_legacy_common_config_usage_marks_historical_provider_enabled() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"legacy-provider\".to_string();\n manager.providers.insert(\n \"legacy-provider\".to_string(),\n Provider::with_id(\n \"legacy-provider\".to_string(),\n \"Legacy\".to_string(),\n json!({\n \"includeCoAuthoredBy\": false,\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"legacy-key\"\n }\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Claude.as_str(),\n Some(r#\"{ \"includeCoAuthoredBy\": false }\"#.to_string()),\n )\n .expect(\"set common config snippet\");\n\n ProviderService::migrate_legacy_common_config_usage_if_needed(&state, AppType::Claude)\n .expect(\"migrate legacy common config\");\n\n let providers = state\n .db\n .get_all_providers(AppType::Claude.as_str())\n .expect(\"get providers after migration\");\n let provider = providers\n .get(\"legacy-provider\")\n .expect(\"legacy provider exists\");\n\n assert_eq!(\n provider\n .meta\n .as_ref()\n .and_then(|meta| meta.common_config_enabled),\n Some(true),\n \"historical provider should be explicitly marked as using common config\"\n );\n assert!(\n provider\n .settings_config\n .get(\"includeCoAuthoredBy\")\n .is_none(),\n \"common config fields should be stripped from provider storage after migration\"\n );\n assert_eq!(\n provider\n .settings_config\n .get(\"env\")\n .and_then(|v| v.get(\"ANTHROPIC_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-key\"),\n \"provider-specific auth should remain untouched\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_updates_live_and_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n enable_codex_official_auth_preservation();\n let _home = ensure_test_home();\n\n let legacy_auth = json!({ \"OPENAI_API_KEY\": \"legacy-key\" });\n let legacy_config = r#\"[mcp_servers.legacy]\ntype = \"stdio\"\ncommand = \"echo\"\n\"#;\n write_codex_live_atomic(&legacy_auth, Some(legacy_config))\n .expect(\"seed existing codex live config\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"old-provider\".to_string();\n manager.providers.insert(\n \"old-provider\".to_string(),\n Provider::with_id(\n \"old-provider\".to_string(),\n \"Legacy\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"stale\"},\n \"config\": \"stale-config\"\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"new-provider\".to_string(),\n Provider::with_id(\n \"new-provider\".to_string(),\n \"Latest\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"fresh-key\"},\n \"config\": r#\"[mcp_servers.latest]\ntype = \"stdio\"\ncommand = \"say\"\n\"#\n }),\n None,\n ),\n );\n }\n\n // 使用新的统一 MCP 结构(v3.7.0+)\n let servers = initial_config\n .mcp\n .servers\n .get_or_insert_with(Default::default);\n servers.insert(\n \"echo-server\".into(),\n McpServer {\n id: \"echo-server\".into(),\n name: \"Echo Server\".into(),\n server: json!({\n \"type\": \"stdio\",\n \"command\": \"echo\"\n }),\n apps: McpApps {\n claude: false,\n codex: true,\n gemini: false,\n opencode: false,\n hermes: false,\n },\n description: None,\n homepage: None,\n docs: None,\n tags: Vec::new(),\n },\n );\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"new-provider\")\n .expect(\"switch provider should succeed\");\n\n let auth_value: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n assert_eq!(\n auth_value.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()),\n Some(\"legacy-key\"),\n \"Codex provider switching should preserve the existing live auth.json\"\n );\n\n let config_text =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n assert!(\n config_text.contains(\"mcp_servers.echo-server\"),\n \"config.toml should contain synced MCP servers\"\n );\n assert!(\n config_text.contains(\"experimental_bearer_token\"),\n \"config.toml should carry the selected provider API key\"\n );\n\n let current_id = state\n .db\n .get_current_provider(AppType::Codex.as_str())\n .expect(\"read current provider after switch\");\n assert_eq!(\n current_id.as_deref(),\n Some(\"new-provider\"),\n \"current provider updated\"\n );\n\n let providers = state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"read providers after switch\");\n\n let new_provider = providers.get(\"new-provider\").expect(\"new provider exists\");\n let new_config_text = new_provider\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or_default();\n // provider 存储的是原始配置,不包含 MCP 同步后的内容\n assert!(\n new_config_text.contains(\"mcp_servers.latest\"),\n \"provider config should contain original MCP servers\"\n );\n // live 文件额外包含同步的 MCP 服务器\n assert!(\n config_text.contains(\"mcp_servers.echo-server\"),\n \"live config should include synced MCP servers\"\n );\n\n let legacy = providers\n .get(\"old-provider\")\n .expect(\"legacy provider still exists\");\n let legacy_auth_value = legacy\n .settings_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n assert_eq!(\n legacy_auth_value, \"legacy-key\",\n \"previous provider should be backfilled with live auth\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_preserves_user_model_provider_id_after_migration() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let legacy_auth = json!({ \"OPENAI_API_KEY\": \"rightcode-key\" });\n let legacy_config = r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n write_codex_live_atomic(&legacy_auth, Some(legacy_config))\n .expect(\"seed existing codex live config\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"old-provider\".to_string();\n manager.providers.insert(\n \"old-provider\".to_string(),\n Provider::with_id(\n \"old-provider\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"stale\"},\n \"config\": legacy_config\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"new-provider\".to_string(),\n Provider::with_id(\n \"new-provider\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"fresh-key\"},\n \"config\": r#\"model_provider = \"aihubmix\"\nmodel = \"gpt-5.4\"\n\n[model_providers.aihubmix]\nname = \"AiHubMix\"\nbase_url = \"https://aihubmix.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"new-provider\")\n .expect(\"switch provider should succeed\");\n\n let config_text =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n let parsed: toml::Value = toml::from_str(&config_text).expect(\"parse config.toml\");\n\n assert_eq!(\n parsed.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider switching should preserve user-editable model_provider after the one-time migration\"\n );\n\n let model_providers = parsed\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"model_providers table exists\");\n assert!(\n model_providers.get(\"custom\").is_none(),\n \"provider switching should not force user-edited provider ids back to custom\"\n );\n assert_eq!(\n model_providers\n .get(\"aihubmix\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"selected provider id should point at the newly selected supplier endpoint\"\n );\n\n let providers = state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"read providers after switch\");\n let new_config_text = providers\n .get(\"new-provider\")\n .expect(\"new provider exists\")\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or_default();\n assert!(\n new_config_text.contains(\"[model_providers.aihubmix]\"),\n \"stored provider template should remain provider-specific\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_preserves_oauth_and_backfills_api_key_from_live_token() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n enable_codex_official_auth_preservation();\n let _home = ensure_test_home();\n\n let live_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"oauth-token\",\n \"account_id\": \"acct-1\"\n }\n });\n let legacy_config = r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n write_codex_live_atomic(&live_auth, Some(legacy_config))\n .expect(\"seed existing Codex OAuth live config\");\n\n let bridge_provider = Provider::with_id(\n \"bridge-provider\".to_string(),\n \"Bridge Provider\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"bridge-key\"},\n \"config\": r#\"model_provider = \"aihubmix\"\nmodel = \"gpt-5.4\"\n\n[model_providers.aihubmix]\nname = \"AiHubMix\"\nbase_url = \"https://aihubmix.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"legacy-provider\".to_string();\n manager.providers.insert(\n \"legacy-provider\".to_string(),\n Provider::with_id(\n \"legacy-provider\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"rightcode-key\"},\n \"config\": legacy_config\n }),\n None,\n ),\n );\n manager\n .providers\n .insert(\"bridge-provider\".to_string(), bridge_provider);\n manager.providers.insert(\n \"plain-provider\".to_string(),\n Provider::with_id(\n \"plain-provider\".to_string(),\n \"Plain Provider\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"plain-key\"},\n \"config\": r#\"model_provider = \"plain\"\nmodel = \"gpt-5.4\"\n\n[model_providers.plain]\nname = \"Plain\"\nbase_url = \"https://plain.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"bridge-provider\")\n .expect(\"switch to bridge provider should succeed\");\n\n let auth_value: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n assert_eq!(\n auth_value.get(\"auth_mode\").and_then(|v| v.as_str()),\n Some(\"chatgpt\")\n );\n assert!(\n auth_value\n .get(\"OPENAI_API_KEY\")\n .is_some_and(|v| v.is_null()),\n \"provider switching should keep OPENAI_API_KEY null in live auth.json\"\n );\n assert_eq!(\n auth_value\n .pointer(\"/tokens/access_token\")\n .and_then(|v| v.as_str()),\n Some(\"oauth-token\"),\n \"existing ChatGPT OAuth token should be preserved\"\n );\n\n let live_config =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n let parsed_live: toml::Value = toml::from_str(&live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .and_then(|v| v.get(\"experimental_bearer_token\"))\n .and_then(|v| v.as_str()),\n Some(\"bridge-key\"),\n \"third-party key should be injected into the selected live provider table\"\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .and_then(|v| v.get(\"requires_openai_auth\"))\n .and_then(|v| v.as_bool()),\n Some(true)\n );\n\n ProviderService::switch(&state, AppType::Codex, \"plain-provider\")\n .expect(\"switch away should backfill bridge provider\");\n\n let providers = state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"read providers\");\n let stored_bridge = providers\n .get(\"bridge-provider\")\n .expect(\"bridge provider exists after backfill\");\n assert_eq!(\n stored_bridge\n .settings_config\n .pointer(\"/auth/OPENAI_API_KEY\")\n .and_then(|v| v.as_str()),\n Some(\"bridge-key\"),\n \"backfill should restore the API key into stored provider auth\"\n );\n assert!(\n stored_bridge\n .settings_config\n .pointer(\"/auth/tokens\")\n .is_none(),\n \"backfill should not persist ChatGPT OAuth tokens into provider storage\"\n );\n assert!(\n !stored_bridge\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or_default()\n .contains(\"experimental_bearer_token\"),\n \"stored provider config should stay clean; bridge token is generated only for live config\"\n );\n}\n\n#[tokio::test(flavor = \"current_thread\")]\n#[allow(\n clippy::await_holding_lock,\n reason = \"this integration-style test must serialize global test HOME and settings mutations across async takeover calls\"\n)]\nasync fn codex_official_to_deepseek_then_takeover_enters_and_restores_proxy_managed_live_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n enable_codex_official_auth_preservation();\n let _home = ensure_test_home();\n\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"access_token\": \"oauth-access\",\n \"id_token\": \"oauth-id\"\n }\n });\n let official_config = r#\"model_provider = \"openai\"\nmodel = \"gpt-5\"\n\n[model_providers.openai]\nname = \"OpenAI\"\nwire_api = \"responses\"\n\"#;\n write_codex_live_atomic(&oauth_auth, Some(official_config))\n .expect(\"seed official Codex OAuth live config\");\n\n let deepseek_provider_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-chat\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"official-provider\".to_string();\n\n let mut official_provider = Provider::with_id(\n \"official-provider\".to_string(),\n \"OpenAI Official\".to_string(),\n json!({\n \"auth\": oauth_auth,\n \"config\": official_config\n }),\n None,\n );\n official_provider.category = Some(\"official\".to_string());\n manager\n .providers\n .insert(\"official-provider\".to_string(), official_provider);\n\n let mut deepseek_provider = Provider::with_id(\n \"deepseek-provider\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"deepseek-key\"},\n \"config\": deepseek_provider_config\n }),\n None,\n );\n deepseek_provider.category = Some(\"custom\".to_string());\n manager\n .providers\n .insert(\"deepseek-provider\".to_string(), deepseek_provider);\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n let mut proxy_config = state.db.get_proxy_config().await.expect(\"get proxy config\");\n proxy_config.listen_port = 0;\n state\n .db\n .update_proxy_config(proxy_config)\n .await\n .expect(\"use ephemeral proxy port\");\n\n ProviderService::switch(&state, AppType::Codex, \"deepseek-provider\")\n .expect(\"switch from official subscription to DeepSeek\");\n\n let auth_after_switch: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth after switch\");\n assert_eq!(\n auth_after_switch, oauth_auth,\n \"normal provider switch with Codex preservation enabled must keep OAuth auth.json\"\n );\n\n let config_after_switch =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config\");\n assert!(\n config_after_switch.contains(\"https://api.deepseek.com/v1\"),\n \"normal switch should write the DeepSeek endpoint before takeover\"\n );\n assert!(\n config_after_switch.contains(\"deepseek-key\"),\n \"normal switch should inject the DeepSeek key into config.toml\"\n );\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"enable Codex takeover\");\n let proxy_status = state\n .proxy_service\n .get_status()\n .await\n .expect(\"read proxy status after takeover\");\n let codex_proxy_base_url = format!(\"http://127.0.0.1:{}/v1\", proxy_status.port);\n\n let auth_after_takeover: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth after takeover\");\n assert_eq!(\n auth_after_takeover, oauth_auth,\n \"enabling takeover must not rewrite Codex OAuth auth.json\"\n );\n\n let config_after_takeover =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config\");\n assert!(\n config_after_takeover.contains(&codex_proxy_base_url),\n \"enabling takeover should point Codex config.toml at the local proxy\"\n );\n assert!(\n config_after_takeover.contains(\"PROXY_MANAGED\"),\n \"enabling takeover should move the proxy placeholder into config.toml\"\n );\n assert!(\n !config_after_takeover.contains(\"https://api.deepseek.com/v1\"),\n \"takeover live config should not keep the upstream DeepSeek endpoint\"\n );\n\n let backup = state\n .db\n .get_live_backup(\"codex\")\n .await\n .expect(\"read Codex backup\")\n .expect(\"backup exists after takeover\");\n let backup_value: serde_json::Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n let backup_config = backup_value\n .get(\"config\")\n .and_then(|value| value.as_str())\n .unwrap_or_default();\n assert!(\n backup_config.contains(\"https://api.deepseek.com/v1\")\n && backup_config.contains(\"deepseek-key\"),\n \"takeover backup should remain the restorable DeepSeek config\"\n );\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n\n let restored_auth: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read restored auth\");\n assert_eq!(\n restored_auth, oauth_auth,\n \"disabling takeover should restore without replacing OAuth auth.json\"\n );\n\n let restored_config = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read restored config\");\n assert!(\n restored_config.contains(\"https://api.deepseek.com/v1\")\n && restored_config.contains(\"deepseek-key\"),\n \"disabling takeover should restore the selected DeepSeek live config\"\n );\n assert!(\n !restored_config.contains(\"PROXY_MANAGED\"),\n \"restored live config must not keep the proxy placeholder\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_default_overwrites_official_auth_when_preservation_off() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n // Intentionally do NOT enable preservation: this locks the default opt-out\n // behavior where switching to a third-party provider rewrites auth.json,\n // discarding the user's ChatGPT OAuth login. It is the dual of\n // `provider_service_switch_codex_preserves_oauth_and_backfills_api_key_from_live_token`.\n let _home = ensure_test_home();\n\n let live_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"official-oauth-token\",\n \"account_id\": \"acct-1\"\n }\n });\n let legacy_config = r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n write_codex_live_atomic(&live_auth, Some(legacy_config))\n .expect(\"seed existing Codex OAuth live config\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"legacy-provider\".to_string();\n manager.providers.insert(\n \"legacy-provider\".to_string(),\n Provider::with_id(\n \"legacy-provider\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"rightcode-key\"},\n \"config\": legacy_config\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"third-party\".to_string(),\n Provider::with_id(\n \"third-party\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"third-party-key\"},\n \"config\": r#\"model_provider = \"aihubmix\"\nmodel = \"gpt-5.4\"\n\n[model_providers.aihubmix]\nname = \"AiHubMix\"\nbase_url = \"https://aihubmix.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"third-party\")\n .expect(\"switch to third-party provider should succeed\");\n\n let auth_value: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n assert_eq!(\n auth_value.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()),\n Some(\"third-party-key\"),\n \"default (preservation off) should overwrite auth.json with the third-party API key\"\n );\n assert!(\n auth_value.pointer(\"/tokens/access_token\").is_none(),\n \"default switch must clear the official ChatGPT OAuth token from live auth.json\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_supports_official_login_provider_without_auth_write() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let live_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"official-oauth-token\",\n \"account_id\": \"acct-official\"\n }\n });\n write_codex_live_atomic(&live_auth, Some(\"\")).expect(\"seed official OAuth live config\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"legacy-provider\".to_string();\n manager.providers.insert(\n \"legacy-provider\".to_string(),\n Provider::with_id(\n \"legacy-provider\".to_string(),\n \"Legacy\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"legacy-key\"},\n \"config\": r#\"model_provider = \"legacy\"\n\n[model_providers.legacy]\nname = \"Legacy\"\nbase_url = \"https://legacy.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n ),\n );\n let mut official_provider = Provider::with_id(\n \"official-provider\".to_string(),\n \"OpenAI Official\".to_string(),\n json!({\n \"auth\": {},\n \"config\": \"\"\n }),\n None,\n );\n official_provider.category = Some(\"official\".to_string());\n manager\n .providers\n .insert(\"official-provider\".to_string(), official_provider);\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"official-provider\")\n .expect(\"switch to official provider should succeed without API key\");\n\n let auth_value: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n assert_eq!(\n auth_value.get(\"auth_mode\").and_then(|v| v.as_str()),\n Some(\"chatgpt\")\n );\n assert!(\n auth_value\n .get(\"OPENAI_API_KEY\")\n .is_some_and(|v| v.is_null()),\n \"official provider switching should keep OPENAI_API_KEY null\"\n );\n assert_eq!(\n auth_value\n .pointer(\"/tokens/access_token\")\n .and_then(|v| v.as_str()),\n Some(\"official-oauth-token\"),\n \"official provider should preserve the existing ChatGPT OAuth token\"\n );\n\n let live_config =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n assert!(\n !live_config.contains(\"experimental_bearer_token\"),\n \"official login provider has no API key to inject\"\n );\n}\n\n#[test]\nfn reapply_codex_official_live_resyncs_mcp_servers() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let live_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": { \"access_token\": \"official-oauth-token\", \"account_id\": \"acct\" }\n });\n write_codex_live_atomic(&live_auth, Some(\"\")).expect(\"seed official live auth\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n let mut official = Provider::with_id(\n \"official-provider\".to_string(),\n \"Official\".to_string(),\n json!({\n \"auth\": {\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": { \"access_token\": \"official-oauth-token\", \"account_id\": \"acct\" }\n },\n \"config\": \"\"\n }),\n None,\n );\n official.category = Some(\"official\".to_string());\n manager\n .providers\n .insert(\"official-provider\".to_string(), official);\n }\n let servers = initial_config\n .mcp\n .servers\n .get_or_insert_with(Default::default);\n servers.insert(\n \"echo-server\".into(),\n McpServer {\n id: \"echo-server\".into(),\n name: \"Echo Server\".into(),\n server: json!({\n \"type\": \"stdio\",\n \"command\": \"echo\"\n }),\n apps: McpApps {\n claude: false,\n codex: true,\n gemini: false,\n opencode: false,\n hermes: false,\n },\n description: None,\n homepage: None,\n docs: None,\n tags: Vec::new(),\n },\n );\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"official-provider\")\n .expect(\"switch to official provider\");\n let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read config.toml after switch\");\n assert!(\n live.contains(\"mcp_servers.echo-server\"),\n \"switch should sync enabled MCP servers into live\"\n );\n\n // 统一会话开关变更触发的 reapply 会整体重写 live config.toml(有意设计),\n // 写完必须重新投影 DB 里启用的 MCP,否则用户的 MCP 会静默失效。\n let reapplied =\n cc_switch_lib::reapply_current_codex_official_live(&state).expect(\"reapply official live\");\n assert!(\n reapplied,\n \"current provider is official, reapply should run\"\n );\n\n let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read config.toml after reapply\");\n assert!(\n live.contains(\"mcp_servers.echo-server\"),\n \"reapply must re-project enabled MCP servers after the full live rewrite, got: {live}\"\n );\n}\n\n/// reapply 走到 MCP 投影时 live 已按新开关状态落盘、开关事实上已生效:\n/// ① 投影失败若上抛,save_settings 会回滚开关设置,制造\"设置=旧值、\n/// live=新桶\"的会话分裂——必须降级为警告而不是失败;\n/// ② 投影必须只针对 Codex:sync_all_enabled 按 AppType::all() 顺序短路,\n/// Claude 排在 Codex 前面,损坏的 ~/.claude.json 会在轮到 Codex 之前\n/// 报错——若吞错了事,刚被整体重写清掉的 [mcp_servers] 就无人补回,\n/// Codex MCP 静默消失。这里用坏 JSON 的 ~/.claude.json 复现该场景。\n#[test]\nfn reapply_codex_official_live_projects_mcp_despite_broken_claude_json() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let live_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": { \"access_token\": \"official-oauth-token\", \"account_id\": \"acct\" }\n });\n write_codex_live_atomic(&live_auth, Some(\"\")).expect(\"seed official live auth\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n let mut official = Provider::with_id(\n \"official-provider\".to_string(),\n \"Official\".to_string(),\n json!({\n \"auth\": {\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": { \"access_token\": \"official-oauth-token\", \"account_id\": \"acct\" }\n },\n \"config\": \"\"\n }),\n None,\n );\n official.category = Some(\"official\".to_string());\n manager\n .providers\n .insert(\"official-provider\".to_string(), official);\n }\n let servers = initial_config\n .mcp\n .servers\n .get_or_insert_with(Default::default);\n servers.insert(\n \"echo-server\".into(),\n McpServer {\n id: \"echo-server\".into(),\n name: \"Echo Server\".into(),\n server: json!({\n \"type\": \"stdio\",\n \"command\": \"echo\"\n }),\n apps: McpApps {\n claude: false,\n codex: true,\n gemini: false,\n opencode: false,\n hermes: false,\n },\n description: None,\n homepage: None,\n docs: None,\n tags: Vec::new(),\n },\n );\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n // 先切换建立\"当前=官方\"的前置状态,再破坏 claude 文件,\n // 让损坏只作用于被测的 reapply 路径。\n ProviderService::switch(&state, AppType::Codex, \"official-provider\")\n .expect(\"switch to official provider\");\n\n // 破坏 ~/.claude.json:坏 JSON 能通过 should_sync_claude_mcp 门控\n // (文件存在即过),但 read_mcp_servers_map 解析必然报错。\n // 注意 codex-only 的服务器也会触发 claude 的 remove 分支读该文件。\n let claude_json = cc_switch_lib::get_claude_mcp_path();\n std::fs::write(&claude_json, \"{ not valid json\").expect(\"seed broken claude json\");\n\n let reapplied = cc_switch_lib::reapply_current_codex_official_live(&state)\n .expect(\"MCP projection failure must degrade to a warning, not fail the toggle\");\n assert!(\n reapplied,\n \"current provider is official, reapply should run\"\n );\n\n // 定向投影不碰 claude:Codex 的 MCP 投影必须完成,不能被\n // 无关应用的损坏文件阻断后静默丢失。\n let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read config.toml after reapply\");\n assert!(\n live.contains(\"mcp_servers.echo-server\"),\n \"codex MCP projection must not be blocked by a broken ~/.claude.json, got: {live}\"\n );\n\n // 顺带锁定:定向投影不应把手改坏的 ~/.claude.json 触碰或\"修复\"。\n let claude_after = std::fs::read_to_string(&claude_json).expect(\"read claude json\");\n assert_eq!(\n claude_after, \"{ not valid json\",\n \"codex-only projection must not touch claude's live file\"\n );\n}\n\n/// 切换供应商与 reapply 是同一类场景:live 整体重写后只需重投影本应用\n/// 的 MCP。历史实现走全量 sync_all_enabled + `?`,损坏的 ~/.claude.json\n/// 会让\"切 Codex\"直接报切换失败——而此时 DB is_current 与 live 都已\n/// 落盘,切换事实上成功,报错只制造分裂假象。\n#[test]\nfn switch_codex_projects_mcp_despite_broken_claude_json() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n write_codex_live_atomic(&json!({ \"OPENAI_API_KEY\": \"sk-old\" }), Some(\"\"))\n .expect(\"seed codex live\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.providers.insert(\n \"p\".to_string(),\n Provider::with_id(\n \"p\".to_string(),\n \"P\".to_string(),\n json!({\n \"auth\": { \"OPENAI_API_KEY\": \"sk-p\" },\n \"config\": \"model = \\\"gpt-5.5\\\"\\n\"\n }),\n None,\n ),\n );\n }\n let servers = config.mcp.servers.get_or_insert_with(Default::default);\n servers.insert(\n \"echo-server\".into(),\n McpServer {\n id: \"echo-server\".into(),\n name: \"Echo Server\".into(),\n server: json!({\n \"type\": \"stdio\",\n \"command\": \"echo\"\n }),\n apps: McpApps {\n claude: false,\n codex: true,\n gemini: false,\n opencode: false,\n hermes: false,\n },\n description: None,\n homepage: None,\n docs: None,\n tags: Vec::new(),\n },\n );\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n // 坏 JSON 能通过 should_sync_claude_mcp 门控(文件存在即过),\n // 但 read_mcp_servers_map 解析必然报错;codex-only 服务器也会\n // 触发 claude 的 remove 分支去读这个文件。\n let claude_json = cc_switch_lib::get_claude_mcp_path();\n std::fs::write(&claude_json, \"{ not valid json\").expect(\"seed broken claude json\");\n\n ProviderService::switch(&state, AppType::Codex, \"p\")\n .expect(\"broken ~/.claude.json must not fail an unrelated codex switch\");\n\n let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read config.toml after switch\");\n assert!(\n live.contains(\"mcp_servers.echo-server\"),\n \"switch must re-project codex MCP after the full live rewrite, got: {live}\"\n );\n\n let claude_after = std::fs::read_to_string(&claude_json).expect(\"read claude json\");\n assert_eq!(\n claude_after, \"{ not valid json\",\n \"codex-only projection must not touch claude's live file\"\n );\n}\n\n/// sync_all_enabled 的全量语义(配置导入 / 云同步恢复):单个应用的\n/// live 损坏不阻断其余应用的投影,但失败必须聚合上报——调用方需要\n/// 知道结果不完整。历史实现按 AppType::all() 顺序 `?` 短路,Claude\n/// 排在 Codex 前面,一份坏 ~/.claude.json 会让所有后续应用的 MCP\n/// 状态永远陈旧。\n#[test]\nfn sync_all_enabled_reports_broken_app_but_projects_the_rest() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n write_codex_live_atomic(&json!({ \"OPENAI_API_KEY\": \"sk\" }), Some(\"\")).expect(\"seed codex live\");\n\n let mut config = MultiAppConfig::default();\n let servers = config.mcp.servers.get_or_insert_with(Default::default);\n servers.insert(\n \"echo-server\".into(),\n McpServer {\n id: \"echo-server\".into(),\n name: \"Echo Server\".into(),\n server: json!({\n \"type\": \"stdio\",\n \"command\": \"echo\"\n }),\n apps: McpApps {\n claude: false,\n codex: true,\n gemini: false,\n opencode: false,\n hermes: false,\n },\n description: None,\n homepage: None,\n docs: None,\n tags: Vec::new(),\n },\n );\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n let claude_json = cc_switch_lib::get_claude_mcp_path();\n std::fs::write(&claude_json, \"{ not valid json\").expect(\"seed broken claude json\");\n\n let err = cc_switch_lib::McpService::sync_all_enabled(&state)\n .expect_err(\"broken claude live must surface as an aggregated error\");\n let message = err.to_string();\n assert!(\n message.contains(\"claude\"),\n \"aggregated error should name the failing app, got: {message}\"\n );\n\n // Claude 的失败不能阻断 Codex:best-effort 必须继续投影其余应用。\n let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read config.toml after sync_all_enabled\");\n assert!(\n live.contains(\"mcp_servers.echo-server\"),\n \"codex projection must proceed despite the broken claude file, got: {live}\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_official_accounts_write_auth_json() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let live_auth_a = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"official-a-live-token\",\n \"account_id\": \"acct-a\"\n }\n });\n write_codex_live_atomic(&live_auth_a, Some(\"\")).expect(\"seed official account A live auth\");\n\n let mut official_a = Provider::with_id(\n \"official-a\".to_string(),\n \"Official A\".to_string(),\n json!({\n \"auth\": {\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"stale-a-token\",\n \"account_id\": \"acct-a\"\n }\n },\n \"config\": \"\"\n }),\n None,\n );\n official_a.category = Some(\"official\".to_string());\n\n let mut official_b = Provider::with_id(\n \"official-b\".to_string(),\n \"Official B\".to_string(),\n json!({\n \"auth\": {\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"official-b-token\",\n \"account_id\": \"acct-b\"\n }\n },\n \"config\": \"\"\n }),\n None,\n );\n official_b.category = Some(\"official\".to_string());\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"official-a\".to_string();\n manager\n .providers\n .insert(\"official-a\".to_string(), official_a);\n manager\n .providers\n .insert(\"official-b\".to_string(), official_b);\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"official-b\")\n .expect(\"switch to official account B should write auth.json\");\n let auth_b: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth B\");\n assert_eq!(\n auth_b\n .pointer(\"/tokens/access_token\")\n .and_then(|v| v.as_str()),\n Some(\"official-b-token\"),\n \"switching official accounts must replace auth.json with the selected account\"\n );\n\n ProviderService::switch(&state, AppType::Codex, \"official-a\")\n .expect(\"switch back to official account A should use backfilled live auth\");\n let auth_a: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth A\");\n assert_eq!(\n auth_a\n .pointer(\"/tokens/access_token\")\n .and_then(|v| v.as_str()),\n Some(\"official-a-live-token\"),\n \"backfill should preserve account A's latest live token for later official switches\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_backfill_keeps_provider_specific_model_provider_id() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let legacy_auth = json!({ \"OPENAI_API_KEY\": \"rightcode-key\" });\n let provider_a_config = r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n write_codex_live_atomic(&legacy_auth, Some(provider_a_config))\n .expect(\"seed existing codex live config\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"provider-a\".to_string();\n manager.providers.insert(\n \"provider-a\".to_string(),\n Provider::with_id(\n \"provider-a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"rightcode-key\"},\n \"config\": provider_a_config\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"provider-b\".to_string(),\n Provider::with_id(\n \"provider-b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"aihubmix-key\"},\n \"config\": r#\"model_provider = \"aihubmix\"\nmodel = \"gpt-5.4\"\nprofile = \"work\"\n\n[model_providers.aihubmix]\nname = \"AiHubMix\"\nbase_url = \"https://aihubmix.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\n[profiles.work]\nmodel_provider = \"aihubmix\"\nmodel = \"gpt-5.4\"\n\"#\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"provider-c\".to_string(),\n Provider::with_id(\n \"provider-c\".to_string(),\n \"Vendor C\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"vendor-c-key\"},\n \"config\": r#\"model_provider = \"vendor_c\"\nmodel = \"gpt-5.4\"\n\n[model_providers.vendor_c]\nname = \"Vendor C\"\nbase_url = \"https://vendor-c.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"provider-b\")\n .expect(\"switch to provider b should succeed\");\n ProviderService::switch(&state, AppType::Codex, \"provider-c\")\n .expect(\"switch to provider c should succeed\");\n\n let providers = state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"read providers after switches\");\n let provider_b_config = providers\n .get(\"provider-b\")\n .expect(\"provider b exists\")\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"provider b config\");\n let parsed: toml::Value = toml::from_str(provider_b_config).expect(\"parse provider b config\");\n\n assert_eq!(\n parsed.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"backfill should restore provider b's storage-specific model_provider id\"\n );\n assert!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .is_some(),\n \"provider b should keep its own model_providers table after backfill\"\n );\n assert_eq!(\n parsed\n .get(\"profiles\")\n .and_then(|v| v.get(\"work\"))\n .and_then(|v| v.get(\"model_provider\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"profile overrides should be restored to provider b's storage-specific id\"\n );\n}\n\n#[test]\nfn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"current-provider\".to_string();\n\n let mut provider = Provider::with_id(\n \"current-provider\".to_string(),\n \"Current\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"real-token\",\n \"ANTHROPIC_BASE_URL\": \"https://claude.example\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n manager\n .providers\n .insert(\"current-provider\".to_string(), provider);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Claude.as_str(),\n Some(r#\"{ \"includeCoAuthoredBy\": false }\"#.to_string()),\n )\n .expect(\"set common config snippet\");\n\n let taken_over_live = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:5000\",\n \"ANTHROPIC_AUTH_TOKEN\": \"PROXY_MANAGED\"\n }\n });\n let settings_path = get_claude_settings_path();\n std::fs::create_dir_all(settings_path.parent().expect(\"settings dir\")).expect(\"create dir\");\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&taken_over_live).expect(\"serialize taken over live\"),\n )\n .expect(\"write taken over live\");\n\n futures::executor::block_on(state.db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\"))\n .expect(\"seed live backup\");\n\n let mut proxy_config = futures::executor::block_on(state.db.get_proxy_config_for_app(\"claude\"))\n .expect(\"get proxy config\");\n proxy_config.enabled = true;\n futures::executor::block_on(state.db.update_proxy_config_for_app(proxy_config))\n .expect(\"enable takeover\");\n\n ProviderService::sync_current_provider_for_app(&state, AppType::Claude)\n .expect(\"sync current provider should succeed\");\n\n let live_after: serde_json::Value =\n read_json_file(&settings_path).expect(\"read live settings after sync\");\n assert_eq!(\n live_after, taken_over_live,\n \"sync should not overwrite live config while takeover is active\"\n );\n\n let backup = futures::executor::block_on(state.db.get_live_backup(\"claude\"))\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let backup_value: serde_json::Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup value\");\n\n assert_eq!(\n backup_value\n .get(\"includeCoAuthoredBy\")\n .and_then(|v| v.as_bool()),\n Some(false),\n \"restore backup should receive the updated effective config\"\n );\n assert_eq!(\n backup_value\n .get(\"env\")\n .and_then(|v| v.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|v| v.as_str()),\n Some(\"real-token\"),\n \"restore backup should preserve the provider token rather than proxy placeholder\"\n );\n}\n\n#[test]\nfn switch_codex_provider_with_takeover_live_but_stopped_proxy_keeps_proxy_live_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n enable_codex_official_auth_preservation();\n let _home = ensure_test_home();\n\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"access_token\": \"oauth-access\",\n \"id_token\": \"oauth-id\"\n }\n });\n let old_provider_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-chat\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"old-key\"\n\"#;\n let proxy_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-chat\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"PROXY_MANAGED\"\n\"#;\n write_codex_live_atomic(&oauth_auth, Some(proxy_live_config))\n .expect(\"seed taken-over Codex live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"old-provider\".to_string();\n\n let mut old_provider = Provider::with_id(\n \"old-provider\".to_string(),\n \"DeepSeek Old\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"old-key\"},\n \"config\": old_provider_config\n }),\n None,\n );\n old_provider.category = Some(\"custom\".to_string());\n manager\n .providers\n .insert(\"old-provider\".to_string(), old_provider);\n\n let mut new_provider = Provider::with_id(\n \"new-provider\".to_string(),\n \"DeepSeek New\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"new-key\"},\n \"config\": r#\"model_provider = \"deepseek-new\"\nmodel = \"deepseek-reasoner\"\n\n[model_providers.deepseek-new]\nname = \"DeepSeek New\"\nbase_url = \"https://new.deepseek.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n new_provider.category = Some(\"custom\".to_string());\n manager\n .providers\n .insert(\"new-provider\".to_string(), new_provider);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n futures::executor::block_on(\n state.db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": oauth_auth,\n \"config\": old_provider_config\n }))\n .expect(\"serialize backup\"),\n ),\n )\n .expect(\"seed Codex live backup\");\n\n assert!(\n !futures::executor::block_on(state.proxy_service.is_running()),\n \"fixture keeps the proxy server stopped\"\n );\n\n ProviderService::switch(&state, AppType::Codex, \"new-provider\")\n .expect(\"switch should update takeover backup instead of writing normal live config\");\n\n let auth_after: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n assert_eq!(\n auth_after, oauth_auth,\n \"provider switch during takeover ownership must not rewrite Codex OAuth auth\"\n );\n\n let live_config =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n assert!(\n live_config.contains(\"http://127.0.0.1:15721/v1\"),\n \"live config should remain pointed at the local proxy\"\n );\n assert!(\n live_config.contains(\"PROXY_MANAGED\"),\n \"live config should keep the proxy bearer placeholder\"\n );\n assert!(\n live_config.contains(r#\"model_provider = \"deepseek-new\"\"#)\n && live_config.contains(r#\"name = \"DeepSeek New\"\"#),\n \"live config should update the Codex-visible provider label during takeover\"\n );\n assert!(\n !live_config.contains(\"https://new.deepseek.example/v1\"),\n \"normal provider base_url must not overwrite taken-over live config\"\n );\n\n let backup = futures::executor::block_on(state.db.get_live_backup(\"codex\"))\n .expect(\"get Codex backup\")\n .expect(\"backup exists\");\n let backup_value: serde_json::Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n assert_eq!(\n backup_value.get(\"auth\"),\n Some(&auth_after),\n \"restore backup should preserve the official OAuth auth\"\n );\n let backup_config = backup_value\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or_default();\n assert!(\n backup_config.contains(\"new-key\") && backup_config.contains(\"deepseek-new\"),\n \"restore backup should be rebuilt from the newly selected provider\"\n );\n\n let current = state\n .db\n .get_current_provider(AppType::Codex.as_str())\n .expect(\"get current provider\");\n assert_eq!(current.as_deref(), Some(\"new-provider\"));\n}\n\n#[test]\nfn explicitly_cleared_common_snippet_is_not_auto_extracted() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let state = create_test_state().expect(\"create test state\");\n state\n .db\n .set_config_snippet_cleared(AppType::Claude.as_str(), true)\n .expect(\"mark snippet explicitly cleared\");\n\n assert!(\n !state\n .db\n .should_auto_extract_config_snippet(AppType::Claude.as_str())\n .expect(\"check auto-extract eligibility\"),\n \"explicitly cleared snippets should block auto-extraction\"\n );\n\n state\n .db\n .set_config_snippet(AppType::Claude.as_str(), Some(\"{}\".to_string()))\n .expect(\"set snippet\");\n state\n .db\n .set_config_snippet_cleared(AppType::Claude.as_str(), false)\n .expect(\"clear explicit-empty marker\");\n\n assert!(\n !state\n .db\n .should_auto_extract_config_snippet(AppType::Claude.as_str())\n .expect(\"check auto-extract after snippet saved\"),\n \"existing snippets should also block auto-extraction\"\n );\n}\n\n#[test]\nfn legacy_common_config_migration_flag_roundtrip() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let state = create_test_state().expect(\"create test state\");\n\n assert!(\n !state\n .db\n .is_legacy_common_config_migrated()\n .expect(\"initial migration flag\"),\n \"migration flag should default to false\"\n );\n\n state\n .db\n .set_legacy_common_config_migrated(true)\n .expect(\"set migration flag\");\n assert!(\n state\n .db\n .is_legacy_common_config_migrated()\n .expect(\"read migration flag\"),\n \"migration flag should persist once set\"\n );\n\n state\n .db\n .set_legacy_common_config_migrated(false)\n .expect(\"clear migration flag\");\n assert!(\n !state\n .db\n .is_legacy_common_config_migrated()\n .expect(\"read migration flag after clear\"),\n \"migration flag should be removable for tests/debugging\"\n );\n}\n\n#[test]\nfn switch_packycode_gemini_updates_security_selected_type() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Gemini)\n .expect(\"gemini manager\");\n manager.current = \"packy-gemini\".to_string();\n manager.providers.insert(\n \"packy-gemini\".to_string(),\n Provider::with_id(\n \"packy-gemini\".to_string(),\n \"PackyCode\".to_string(),\n json!({\n \"env\": {\n \"GEMINI_API_KEY\": \"pk-key\",\n \"GOOGLE_GEMINI_BASE_URL\": \"https://www.packyapi.com\"\n }\n }),\n Some(\"https://www.packyapi.com\".to_string()),\n ),\n );\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Gemini, \"packy-gemini\")\n .expect(\"switching to PackyCode Gemini should succeed\");\n\n // Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json\n let settings_path = home.join(\".gemini\").join(\"settings.json\");\n assert!(\n settings_path.exists(),\n \"Gemini settings.json should exist at {}\",\n settings_path.display()\n );\n let raw = std::fs::read_to_string(&settings_path).expect(\"read gemini settings.json\");\n let value: serde_json::Value =\n serde_json::from_str(&raw).expect(\"parse gemini settings.json after switch\");\n\n assert_eq!(\n value\n .pointer(\"/security/auth/selectedType\")\n .and_then(|v| v.as_str()),\n Some(\"gemini-api-key\"),\n \"PackyCode Gemini should set security.auth.selectedType\"\n );\n}\n\n#[test]\nfn packycode_partner_meta_triggers_security_flag_even_without_keywords() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Gemini)\n .expect(\"gemini manager\");\n manager.current = \"packy-meta\".to_string();\n let mut provider = Provider::with_id(\n \"packy-meta\".to_string(),\n \"Generic Gemini\".to_string(),\n json!({\n \"env\": {\n \"GEMINI_API_KEY\": \"pk-meta\",\n \"GOOGLE_GEMINI_BASE_URL\": \"https://generativelanguage.googleapis.com\"\n }\n }),\n Some(\"https://example.com\".to_string()),\n );\n provider.meta = Some(ProviderMeta {\n partner_promotion_key: Some(\"packycode\".to_string()),\n ..ProviderMeta::default()\n });\n manager.providers.insert(\"packy-meta\".to_string(), provider);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Gemini, \"packy-meta\")\n .expect(\"switching to partner meta provider should succeed\");\n\n // Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json\n let settings_path = home.join(\".gemini\").join(\"settings.json\");\n assert!(\n settings_path.exists(),\n \"Gemini settings.json should exist at {}\",\n settings_path.display()\n );\n let raw = std::fs::read_to_string(&settings_path).expect(\"read gemini settings.json\");\n let value: serde_json::Value =\n serde_json::from_str(&raw).expect(\"parse gemini settings.json after switch\");\n\n assert_eq!(\n value\n .pointer(\"/security/auth/selectedType\")\n .and_then(|v| v.as_str()),\n Some(\"gemini-api-key\"),\n \"Partner meta should set security.auth.selectedType even without packy keywords\"\n );\n}\n\n#[test]\nfn switch_google_official_gemini_preserves_env_vars() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Gemini)\n .expect(\"gemini manager\");\n manager.current = \"google-official\".to_string();\n let mut provider = Provider::with_id(\n \"google-official\".to_string(),\n \"Google\".to_string(),\n json!({\n \"env\": {\n \"GEMINI_MODEL\": \"gemini-2.5-pro\"\n }\n }),\n Some(\"https://ai.google.dev\".to_string()),\n );\n provider.meta = Some(ProviderMeta {\n partner_promotion_key: Some(\"google-official\".to_string()),\n ..ProviderMeta::default()\n });\n manager\n .providers\n .insert(\"google-official\".to_string(), provider);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Gemini, \"google-official\")\n .expect(\"switching to Google official Gemini should succeed\");\n\n // Verify env vars are preserved in ~/.gemini/.env\n let env_path = home.join(\".gemini\").join(\".env\");\n assert!(\n env_path.exists(),\n \"Gemini .env should exist at {}\",\n env_path.display()\n );\n let env_content = std::fs::read_to_string(&env_path).expect(\"read gemini .env\");\n assert!(\n env_content.contains(\"GEMINI_MODEL=gemini-2.5-pro\"),\n \"GEMINI_MODEL should be preserved in .env, got: {env_content}\"\n );\n\n // Verify OAuth security flag is still set correctly\n let gemini_settings = home.join(\".gemini\").join(\"settings.json\");\n let gemini_raw = std::fs::read_to_string(&gemini_settings).expect(\"read gemini settings\");\n let gemini_value: serde_json::Value =\n serde_json::from_str(&gemini_raw).expect(\"parse gemini settings\");\n assert_eq!(\n gemini_value\n .pointer(\"/security/auth/selectedType\")\n .and_then(|v| v.as_str()),\n Some(\"oauth-personal\"),\n \"OAuth security flag should still be set\"\n );\n}\n\n#[test]\nfn provider_service_switch_claude_updates_live_and_state() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let settings_path = get_claude_settings_path();\n if let Some(parent) = settings_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n }\n let legacy_live = json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"legacy-key\"\n },\n \"workspace\": {\n \"path\": \"/tmp/workspace\"\n }\n });\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&legacy_live).expect(\"serialize legacy live\"),\n )\n .expect(\"seed claude live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"old-provider\".to_string();\n manager.providers.insert(\n \"old-provider\".to_string(),\n Provider::with_id(\n \"old-provider\".to_string(),\n \"Legacy Claude\".to_string(),\n json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"stale-key\" }\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"new-provider\".to_string(),\n Provider::with_id(\n \"new-provider\".to_string(),\n \"Fresh Claude\".to_string(),\n json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"fresh-key\" },\n \"workspace\": { \"path\": \"/tmp/new-workspace\" }\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Claude, \"new-provider\")\n .expect(\"switch provider should succeed\");\n\n let live_after: serde_json::Value =\n read_json_file(&settings_path).expect(\"read claude live settings\");\n assert_eq!(\n live_after\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .and_then(|key| key.as_str()),\n Some(\"fresh-key\"),\n \"live settings.json should reflect new provider auth\"\n );\n\n let providers = state\n .db\n .get_all_providers(AppType::Claude.as_str())\n .expect(\"get all providers\");\n let current_id = state\n .db\n .get_current_provider(AppType::Claude.as_str())\n .expect(\"get current provider\");\n assert_eq!(\n current_id.as_deref(),\n Some(\"new-provider\"),\n \"current provider updated\"\n );\n\n let legacy_provider = providers\n .get(\"old-provider\")\n .expect(\"legacy provider still exists\");\n assert_eq!(\n legacy_provider.settings_config, legacy_live,\n \"previous provider should receive backfilled live config\"\n );\n}\n\n/// 切走勾选了通用配置的 Claude 供应商时,应把它 live 里新增的可共享键\n/// (用户直接在应用内装插件/改偏好)捕获进通用配置片段,并带到下一个供应商。\n#[test]\nfn switch_claude_syncs_new_shared_keys_from_live_into_common_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let settings_path = get_claude_settings_path();\n if let Some(parent) = settings_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n }\n // A 的 live = A 私有密钥(含非 Anthropic 的 OpenRouter 凭据)+ 已共享的 theme\n // + 用户刚在应用内新增的 enableAllProjectMcpServers\n let live = json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\", \"OPENROUTER_API_KEY\": \"sk-or-leak\" },\n \"theme\": \"dark\",\n \"enableAllProjectMcpServers\": true\n });\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&live).expect(\"serialize live\"),\n )\n .expect(\"seed claude live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"a\".to_string();\n let mut provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" } }),\n None,\n );\n provider_a.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"a\".to_string(), provider_a);\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"b-key\" } }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"b\".to_string(), provider_b);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Claude.as_str(),\n Some(r#\"{\"theme\":\"dark\"}\"#.to_string()),\n )\n .expect(\"seed common config snippet\");\n\n ProviderService::switch(&state, AppType::Claude, \"b\").expect(\"switch should succeed\");\n\n // 片段应捕获到新增键,并保留已有共享键,且绝不含密钥\n let snippet = state\n .db\n .get_config_snippet(AppType::Claude.as_str())\n .expect(\"read snippet\")\n .expect(\"snippet present\");\n let snippet_value: serde_json::Value =\n serde_json::from_str(&snippet).expect(\"snippet is valid JSON\");\n assert_eq!(\n snippet_value.get(\"enableAllProjectMcpServers\"),\n Some(&json!(true)),\n \"newly added shared key should be captured into common config\"\n );\n assert_eq!(\n snippet_value.get(\"theme\").and_then(|v| v.as_str()),\n Some(\"dark\"),\n \"previously shared key should be preserved\"\n );\n assert!(\n snippet_value\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"secrets must never leak into the shared snippet\"\n );\n assert!(\n snippet_value\n .get(\"env\")\n .and_then(|env| env.get(\"OPENROUTER_API_KEY\"))\n .is_none(),\n \"non-Anthropic Claude credentials must never leak into the shared snippet\"\n );\n\n // 新增键应通过通用配置带到 B 的 live\n let live_after: serde_json::Value =\n read_json_file(&settings_path).expect(\"read live after switch\");\n assert_eq!(\n live_after.get(\"enableAllProjectMcpServers\"),\n Some(&json!(true)),\n \"shared key should propagate to the next provider's live config\"\n );\n assert!(\n live_after\n .get(\"env\")\n .and_then(|env| env.get(\"OPENROUTER_API_KEY\"))\n .is_none(),\n \"leaked credential must not be injected into the next provider's live\"\n );\n assert_eq!(\n live_after\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"b-key\"),\n \"live should reflect new provider's own auth\"\n );\n}\n\n/// 用户在应用内删掉一个已共享的键后,切换应把删除同步进通用配置,\n/// 且不会在切到下一个供应商时被重新注入(否则会\"删不掉\")。\n#[test]\nfn switch_claude_syncs_deletions_from_live_into_common_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let settings_path = get_claude_settings_path();\n if let Some(parent) = settings_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n }\n // live 里 theme 还在,但用户已删掉 enableAllProjectMcpServers\n let live = json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" },\n \"theme\": \"dark\"\n });\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&live).expect(\"serialize live\"),\n )\n .expect(\"seed claude live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"a\".to_string();\n let mut provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" } }),\n None,\n );\n provider_a.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"a\".to_string(), provider_a);\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"b-key\" } }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"b\".to_string(), provider_b);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n // 片段里仍残留 enableAllProjectMcpServers(上次共享的)\n state\n .db\n .set_config_snippet(\n AppType::Claude.as_str(),\n Some(r#\"{\"theme\":\"dark\",\"enableAllProjectMcpServers\":true}\"#.to_string()),\n )\n .expect(\"seed common config snippet\");\n\n ProviderService::switch(&state, AppType::Claude, \"b\").expect(\"switch should succeed\");\n\n let snippet = state\n .db\n .get_config_snippet(AppType::Claude.as_str())\n .expect(\"read snippet\")\n .expect(\"snippet present\");\n let snippet_value: serde_json::Value =\n serde_json::from_str(&snippet).expect(\"snippet is valid JSON\");\n assert!(\n snippet_value.get(\"enableAllProjectMcpServers\").is_none(),\n \"deleted key should be removed from common config\"\n );\n assert_eq!(\n snippet_value.get(\"theme\").and_then(|v| v.as_str()),\n Some(\"dark\"),\n \"untouched shared key should remain\"\n );\n\n // 切到 B 后 live 不应再出现被删除的键\n let live_after: serde_json::Value =\n read_json_file(&settings_path).expect(\"read live after switch\");\n assert!(\n live_after.get(\"enableAllProjectMcpServers\").is_none(),\n \"deleted shared key must not be re-injected into the next provider\"\n );\n}\n\n/// Codex 版切换自动回写:live 里新增的共享键被捕获进通用配置片段并传递给\n/// 下一个供应商;供应商专属字段、密钥与 cc-switch 注入产物绝不进片段;\n/// 回填后旧供应商的存储配置不残留片段内容(autosync 先于 strip,值必然匹配)。\n#[test]\nfn switch_codex_syncs_shared_keys_from_live_into_common_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n // A 激活状态下的 live:A 专属路由 + 已共享的 [tui] + 用户刚加的\n // disable_response_storage + cc-switch 注入产物 + MCP 同步投影\n // + 顶层 wire_api(无 model_provider 时的 fallback 写法,属 A 的路由语义)\n // + 历史错误格式 [mcp.servers](sync_all_enabled 清不掉的孤儿形态)\n let live_config = r#\"model = \"gpt-5.5\"\nmodel_provider = \"aprov\"\nwire_api = \"chat\"\nexperimental_bearer_token = \"sk-a-live-secret\"\nmodel_catalog_json = \"cc-switch-model-catalog.json\"\nweb_search = \"disabled\"\ndisable_response_storage = true\n\n[tui]\nnotifications = true\n\n[model_providers.aprov]\nname = \"A Prov\"\nbase_url = \"https://a.example/v1\"\nwire_api = \"responses\"\n\n[mcp_servers.echo]\ntype = \"stdio\"\ncommand = \"echo\"\n\n[mcp.servers.ghost-legacy]\ncommand = \"ghost-cmd\"\n\"#;\n write_codex_live_atomic(&json!({ \"OPENAI_API_KEY\": \"sk-a\" }), Some(live_config))\n .expect(\"seed codex live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"a\".to_string();\n let mut provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"auth\": { \"OPENAI_API_KEY\": \"sk-a\" },\n \"config\": \"model = \\\"gpt-5.5\\\"\\nmodel_provider = \\\"aprov\\\"\\n\\n[model_providers.aprov]\\nname = \\\"A Prov\\\"\\nbase_url = \\\"https://a.example/v1\\\"\\nwire_api = \\\"responses\\\"\\n\"\n }),\n None,\n );\n provider_a.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"a\".to_string(), provider_a);\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"auth\": { \"OPENAI_API_KEY\": \"sk-b\" },\n \"config\": \"model = \\\"gpt-5.5\\\"\\nmodel_provider = \\\"bprov\\\"\\n\\n[model_providers.bprov]\\nname = \\\"B Prov\\\"\\nbase_url = \\\"https://b.example/v1\\\"\\nwire_api = \\\"responses\\\"\\n\"\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"b\".to_string(), provider_b);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Codex.as_str(),\n Some(\"[tui]\\nnotifications = true\\n\".to_string()),\n )\n .expect(\"seed codex common config snippet\");\n\n ProviderService::switch(&state, AppType::Codex, \"b\").expect(\"switch should succeed\");\n\n // 片段:捕获新增共享键、保留既有共享键;专属字段/密钥/注入产物一律不进\n let snippet = state\n .db\n .get_config_snippet(AppType::Codex.as_str())\n .expect(\"read snippet\")\n .expect(\"snippet present\");\n assert!(\n snippet.contains(\"disable_response_storage = true\"),\n \"newly added shared key should be captured, got: {snippet}\"\n );\n assert!(\n snippet.contains(\"notifications = true\"),\n \"previously shared key should be preserved, got: {snippet}\"\n );\n for forbidden in [\n \"experimental_bearer_token\",\n \"sk-a-live-secret\",\n \"model_catalog_json\",\n \"web_search\",\n \"mcp_servers\",\n \"model_providers\",\n \"model_provider\",\n \"wire_api\",\n \"ghost-legacy\",\n ] {\n assert!(\n !snippet.contains(forbidden),\n \"'{forbidden}' must never enter the shared snippet, got: {snippet}\"\n );\n }\n\n // B 的 live:共享键传递到位,A 的密钥/投影不得跟过来\n let live_after = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read config.toml after switch\");\n assert!(\n live_after.contains(\"disable_response_storage = true\"),\n \"shared key should propagate to the next provider's live, got: {live_after}\"\n );\n assert!(\n live_after.contains(\"model_provider = \\\"bprov\\\"\"),\n \"live should be provider B's own config, got: {live_after}\"\n );\n assert!(\n !live_after.contains(\"sk-a-live-secret\"),\n \"provider A's bearer token must not leak into B's live, got: {live_after}\"\n );\n assert!(\n !live_after.contains(\"mcp_servers\"),\n \"no DB-enabled MCP servers, so live must not resurrect stale entries, got: {live_after}\"\n );\n assert!(\n !live_after.contains(\"ghost-legacy\"),\n \"the legacy [mcp.servers] orphan must not propagate to B's live, got: {live_after}\"\n );\n assert!(\n !live_after.contains(\"wire_api = \\\"chat\\\"\"),\n \"provider A's top-level wire_api must not rewrite B's protocol, got: {live_after}\"\n );\n\n // A 的存储配置:回填后不残留片段内容 / MCP 投影 / 注入产物\n let providers = state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"read providers after switch\");\n let stored_a = providers.get(\"a\").expect(\"provider a exists\");\n let stored_a_config = stored_a\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or_default();\n assert!(\n stored_a_config.contains(\"model_provider = \\\"aprov\\\"\"),\n \"provider-owned routing must survive backfill, got: {stored_a_config}\"\n );\n // 顶层 wire_api 是 A 自己的路由语义:不进片段,但回填时留在 A 的快照里\n assert!(\n stored_a_config.contains(\"wire_api = \\\"chat\\\"\"),\n \"provider-owned top-level wire_api must survive backfill, got: {stored_a_config}\"\n );\n for forbidden in [\n \"disable_response_storage\",\n \"notifications\",\n \"mcp_servers\",\n \"experimental_bearer_token\",\n \"ghost-legacy\",\n ] {\n assert!(\n !stored_a_config.contains(forbidden),\n \"'{forbidden}' must be stripped from the stored provider config on backfill, got: {stored_a_config}\"\n );\n }\n}\n\n/// Codex 版删除同步:用户在 live 里删掉一个已共享的键后,切换应把删除\n/// 同步进通用配置,且不会在切到下一个供应商时被重新注入(否则\"删不掉\")。\n#[test]\nfn switch_codex_syncs_deletions_from_live_into_common_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n // 片段里有两个共享键,但用户已在 live 里删掉 disable_response_storage\n let live_config = r#\"model_provider = \"aprov\"\n\n[tui]\nnotifications = true\n\n[model_providers.aprov]\nname = \"A Prov\"\nbase_url = \"https://a.example/v1\"\nwire_api = \"responses\"\n\"#;\n write_codex_live_atomic(&json!({ \"OPENAI_API_KEY\": \"sk-a\" }), Some(live_config))\n .expect(\"seed codex live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"a\".to_string();\n for (id, name, prov_key) in [(\"a\", \"A\", \"aprov\"), (\"b\", \"B\", \"bprov\")] {\n let mut provider = Provider::with_id(\n id.to_string(),\n name.to_string(),\n json!({\n \"auth\": { \"OPENAI_API_KEY\": format!(\"sk-{id}\") },\n \"config\": format!(\"model_provider = \\\"{prov_key}\\\"\\n\\n[model_providers.{prov_key}]\\nname = \\\"{name} Prov\\\"\\nbase_url = \\\"https://{id}.example/v1\\\"\\nwire_api = \\\"responses\\\"\\n\")\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(id.to_string(), provider);\n }\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Codex.as_str(),\n Some(\"disable_response_storage = true\\n\\n[tui]\\nnotifications = true\\n\".to_string()),\n )\n .expect(\"seed codex common config snippet\");\n\n ProviderService::switch(&state, AppType::Codex, \"b\").expect(\"switch should succeed\");\n\n let snippet = state\n .db\n .get_config_snippet(AppType::Codex.as_str())\n .expect(\"read snippet\")\n .expect(\"snippet present\");\n assert!(\n !snippet.contains(\"disable_response_storage\"),\n \"deleted shared key must be removed from the snippet, got: {snippet}\"\n );\n assert!(\n snippet.contains(\"notifications = true\"),\n \"kept shared key should remain in the snippet, got: {snippet}\"\n );\n\n let live_after = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read config.toml after switch\");\n assert!(\n !live_after.contains(\"disable_response_storage\"),\n \"deleted shared key must not be re-injected into the next provider, got: {live_after}\"\n );\n assert!(\n live_after.contains(\"notifications = true\"),\n \"kept shared key should propagate to the next provider, got: {live_after}\"\n );\n}\n\n/// 未勾选\"写入通用配置\"的供应商,其 live 改动不应自动污染通用配置片段。\n#[test]\nfn switch_claude_does_not_sync_common_config_for_opted_out_provider() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let settings_path = get_claude_settings_path();\n if let Some(parent) = settings_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n }\n let live = json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" },\n \"providerSpecific\": \"x\"\n });\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&live).expect(\"serialize live\"),\n )\n .expect(\"seed claude live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"a\".to_string();\n // A 未勾选通用配置(meta = None)\n manager.providers.insert(\n \"a\".to_string(),\n Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" } }),\n None,\n ),\n );\n manager.providers.insert(\n \"b\".to_string(),\n Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"b-key\" } }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Claude.as_str(),\n Some(r#\"{\"theme\":\"dark\"}\"#.to_string()),\n )\n .expect(\"seed common config snippet\");\n\n ProviderService::switch(&state, AppType::Claude, \"b\").expect(\"switch should succeed\");\n\n let snippet = state\n .db\n .get_config_snippet(AppType::Claude.as_str())\n .expect(\"read snippet\")\n .expect(\"snippet present\");\n let snippet_value: serde_json::Value =\n serde_json::from_str(&snippet).expect(\"snippet is valid JSON\");\n assert!(\n snippet_value.get(\"providerSpecific\").is_none(),\n \"opted-out provider's live changes must not pollute the shared snippet\"\n );\n assert_eq!(\n snippet_value.get(\"theme\").and_then(|v| v.as_str()),\n Some(\"dark\"),\n \"snippet should stay unchanged for opted-out providers\"\n );\n}\n\n/// 用户显式清空过通用配置(_cleared)后,切换不应把片段重新塞回来。\n#[test]\nfn switch_claude_respects_explicitly_cleared_common_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let settings_path = get_claude_settings_path();\n if let Some(parent) = settings_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n }\n let live = json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" },\n \"theme\": \"dark\"\n });\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&live).expect(\"serialize live\"),\n )\n .expect(\"seed claude live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"a\".to_string();\n let mut provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" } }),\n None,\n );\n provider_a.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"a\".to_string(), provider_a);\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"b-key\" } }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"b\".to_string(), provider_b);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet_cleared(AppType::Claude.as_str(), true)\n .expect(\"mark snippet cleared\");\n\n ProviderService::switch(&state, AppType::Claude, \"b\").expect(\"switch should succeed\");\n\n assert!(\n state\n .db\n .get_config_snippet(AppType::Claude.as_str())\n .expect(\"read snippet\")\n .is_none(),\n \"explicitly cleared snippet must not be resurrected by switch-away sync\"\n );\n}\n\n#[test]\nfn provider_service_switch_missing_provider_returns_error() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let state = create_test_state().expect(\"create test state\");\n\n let err = ProviderService::switch(&state, AppType::Claude, \"missing\")\n .expect_err(\"switching missing provider should fail\");\n match err {\n AppError::Message(msg) => {\n assert!(\n msg.contains(\"不存在\") || msg.contains(\"not found\"),\n \"expected provider not found message, got {msg}\"\n );\n }\n other => panic!(\"expected Message error for provider not found, got {other:?}\"),\n }\n}\n\n#[test]\nfn provider_service_switch_codex_missing_auth_returns_error() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.providers.insert(\n \"invalid\".to_string(),\n Provider::with_id(\n \"invalid\".to_string(),\n \"Broken Codex\".to_string(),\n json!({\n \"config\": \"[mcp_servers.test]\\ncommand = \\\"noop\\\"\"\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n let err = ProviderService::switch(&state, AppType::Codex, \"invalid\")\n .expect_err(\"switching should fail without auth\");\n match err {\n AppError::Config(msg) => assert!(\n msg.contains(\"auth\"),\n \"expected auth related message, got {msg}\"\n ),\n other => panic!(\"expected config error, got {other:?}\"),\n }\n}\n\n#[test]\nfn provider_service_delete_codex_removes_provider_and_files() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"keep\".to_string();\n manager.providers.insert(\n \"keep\".to_string(),\n Provider::with_id(\n \"keep\".to_string(),\n \"Keep\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"keep-key\"},\n \"config\": \"\"\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"to-delete\".to_string(),\n Provider::with_id(\n \"to-delete\".to_string(),\n \"DeleteCodex\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"delete-key\"},\n \"config\": \"\"\n }),\n None,\n ),\n );\n }\n\n let sanitized = sanitize_provider_name(\"DeleteCodex\");\n let codex_dir = home.join(\".codex\");\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n let auth_path = codex_dir.join(format!(\"auth-{sanitized}.json\"));\n let cfg_path = codex_dir.join(format!(\"config-{sanitized}.toml\"));\n std::fs::write(&auth_path, \"{}\").expect(\"seed auth file\");\n std::fs::write(&cfg_path, \"base_url = \\\"https://example\\\"\").expect(\"seed config file\");\n\n let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::delete(&app_state, AppType::Codex, \"to-delete\")\n .expect(\"delete provider should succeed\");\n\n let providers = app_state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"get all providers\");\n assert!(\n !providers.contains_key(\"to-delete\"),\n \"provider entry should be removed\"\n );\n // v3.7.0+ 不再使用供应商特定文件(如 auth-*.json, config-*.toml)\n // 删除供应商只影响数据库记录,不清理这些旧格式文件\n}\n\n#[test]\nfn provider_service_delete_claude_removes_provider_files() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"keep\".to_string();\n manager.providers.insert(\n \"keep\".to_string(),\n Provider::with_id(\n \"keep\".to_string(),\n \"Keep\".to_string(),\n json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"keep-key\" }\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"delete\".to_string(),\n Provider::with_id(\n \"delete\".to_string(),\n \"DeleteClaude\".to_string(),\n json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"delete-key\" }\n }),\n None,\n ),\n );\n }\n\n let sanitized = sanitize_provider_name(\"DeleteClaude\");\n let claude_dir = home.join(\".claude\");\n std::fs::create_dir_all(&claude_dir).expect(\"create claude dir\");\n let by_name = claude_dir.join(format!(\"settings-{sanitized}.json\"));\n let by_id = claude_dir.join(\"settings-delete.json\");\n std::fs::write(&by_name, \"{}\").expect(\"seed settings by name\");\n std::fs::write(&by_id, \"{}\").expect(\"seed settings by id\");\n\n let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::delete(&app_state, AppType::Claude, \"delete\").expect(\"delete claude provider\");\n\n let providers = app_state\n .db\n .get_all_providers(AppType::Claude.as_str())\n .expect(\"get all providers\");\n assert!(\n !providers.contains_key(\"delete\"),\n \"claude provider should be removed\"\n );\n // v3.7.0+ 不再使用供应商特定文件(如 settings-*.json)\n // 删除供应商只影响数据库记录,不清理这些旧格式文件\n}\n\n#[test]\nfn provider_service_delete_current_provider_returns_error() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"keep\".to_string();\n manager.providers.insert(\n \"keep\".to_string(),\n Provider::with_id(\n \"keep\".to_string(),\n \"Keep\".to_string(),\n json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"keep-key\" }\n }),\n None,\n ),\n );\n }\n\n let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n let err = ProviderService::delete(&app_state, AppType::Claude, \"keep\")\n .expect_err(\"deleting current provider should fail\");\n match err {\n AppError::Localized { zh, .. } => assert!(\n zh.contains(\"不能删除当前正在使用的供应商\")\n || zh.contains(\"无法删除当前正在使用的供应商\"),\n \"unexpected message: {zh}\"\n ),\n AppError::Config(msg) => assert!(\n msg.contains(\"不能删除当前正在使用的供应商\")\n || msg.contains(\"无法删除当前正在使用的供应商\"),\n \"unexpected message: {msg}\"\n ),\n AppError::Message(msg) => assert!(\n msg.contains(\"不能删除当前正在使用的供应商\")\n || msg.contains(\"无法删除当前正在使用的供应商\"),\n \"unexpected message: {msg}\"\n ),\n other => panic!(\"expected Config/Message error, got {other:?}\"),\n }\n}\n\n#[test]\nfn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_back() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n // 接管态 Claude Live,且 DB 中无备份(模拟切换 app_config_dir 后新库首启的场景)\n let taken_over_live = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_AUTH_TOKEN\": \"PROXY_MANAGED\"\n }\n });\n let settings_path = get_claude_settings_path();\n std::fs::create_dir_all(settings_path.parent().expect(\"settings dir\")).expect(\"create dir\");\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&taken_over_live).expect(\"serialize taken over live\"),\n )\n .expect(\"write taken over live\");\n\n let state = create_test_state().expect(\"create test state\");\n\n // 模拟历史异常:接管态 Live 已被导入成 current provider(SSOT 被污染)\n let provider = Provider::with_id(\n \"default\".to_string(),\n \"default\".to_string(),\n taken_over_live.clone(),\n None,\n );\n state\n .db\n .save_provider(AppType::Claude.as_str(), &provider)\n .expect(\"save placeholder provider\");\n state\n .db\n .set_current_provider(AppType::Claude.as_str(), \"default\")\n .expect(\"set current provider\");\n\n futures::executor::block_on(state.proxy_service.recover_from_crash())\n .expect(\"recover from crash\");\n\n let live_after: serde_json::Value =\n read_json_file(&settings_path).expect(\"read live settings after recovery\");\n let env = live_after.get(\"env\").cloned().unwrap_or_else(|| json!({}));\n assert_ne!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"PROXY_MANAGED\"),\n \"recovery must not write the placeholder back to live\"\n );\n assert!(\n env.get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(|url| !url.starts_with(\"http://127.0.0.1\"))\n .unwrap_or(true),\n \"recovery must drop the local proxy base URL\"\n );\n}\n"}, "0950b45609411587ce879641b25ac84f8e452fef": {"t": 1785761095.045706, "n": 98652, "h": "bbbb0a8899813ee2", "c": "use serde_json::json;\n\nuse cc_switch_lib::{\n get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType, McpApps,\n McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,\n};\n\n#[path = \"support.rs\"]\nmod support;\nuse support::{\n create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,\n ensure_test_home, reset_test_fs, test_mutex,\n};\n\nfn sanitize_provider_name(name: &str) -> String {\n name.chars()\n .map(|c| match c {\n '<' | '>' | ':' | '\"' | '/' | '\\\\' | '|' | '?' | '*' => '-',\n _ => c,\n })\n .collect::<String>()\n .to_lowercase()\n}\n\n#[test]\nfn migrate_legacy_common_config_usage_marks_historical_provider_enabled() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"legacy-provider\".to_string();\n manager.providers.insert(\n \"legacy-provider\".to_string(),\n Provider::with_id(\n \"legacy-provider\".to_string(),\n \"Legacy\".to_string(),\n json!({\n \"includeCoAuthoredBy\": false,\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"legacy-key\"\n }\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Claude.as_str(),\n Some(r#\"{ \"includeCoAuthoredBy\": false }\"#.to_string()),\n )\n .expect(\"set common config snippet\");\n\n ProviderService::migrate_legacy_common_config_usage_if_needed(&state, AppType::Claude)\n .expect(\"migrate legacy common config\");\n\n let providers = state\n .db\n .get_all_providers(AppType::Claude.as_str())\n .expect(\"get providers after migration\");\n let provider = providers\n .get(\"legacy-provider\")\n .expect(\"legacy provider exists\");\n\n assert_eq!(\n provider\n .meta\n .as_ref()\n .and_then(|meta| meta.common_config_enabled),\n Some(true),\n \"historical provider should be explicitly marked as using common config\"\n );\n assert!(\n provider\n .settings_config\n .get(\"includeCoAuthoredBy\")\n .is_none(),\n \"common config fields should be stripped from provider storage after migration\"\n );\n assert_eq!(\n provider\n .settings_config\n .get(\"env\")\n .and_then(|v| v.get(\"ANTHROPIC_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-key\"),\n \"provider-specific auth should remain untouched\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_updates_live_and_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n enable_codex_official_auth_preservation();\n let _home = ensure_test_home();\n\n let legacy_auth = json!({ \"OPENAI_API_KEY\": \"legacy-key\" });\n let legacy_config = r#\"[mcp_servers.legacy]\ntype = \"stdio\"\ncommand = \"echo\"\n\"#;\n write_codex_live_atomic(&legacy_auth, Some(legacy_config))\n .expect(\"seed existing codex live config\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"old-provider\".to_string();\n manager.providers.insert(\n \"old-provider\".to_string(),\n Provider::with_id(\n \"old-provider\".to_string(),\n \"Legacy\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"stale\"},\n \"config\": \"stale-config\"\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"new-provider\".to_string(),\n Provider::with_id(\n \"new-provider\".to_string(),\n \"Latest\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"fresh-key\"},\n \"config\": r#\"[mcp_servers.latest]\ntype = \"stdio\"\ncommand = \"say\"\n\"#\n }),\n None,\n ),\n );\n }\n\n // 使用新的统一 MCP 结构(v3.7.0+)\n let servers = initial_config\n .mcp\n .servers\n .get_or_insert_with(Default::default);\n servers.insert(\n \"echo-server\".into(),\n McpServer {\n id: \"echo-server\".into(),\n name: \"Echo Server\".into(),\n server: json!({\n \"type\": \"stdio\",\n \"command\": \"echo\"\n }),\n apps: McpApps {\n claude: false,\n codex: true,\n gemini: false,\n opencode: false,\n hermes: false,\n },\n description: None,\n homepage: None,\n docs: None,\n tags: Vec::new(),\n },\n );\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"new-provider\")\n .expect(\"switch provider should succeed\");\n\n let auth_value: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n assert_eq!(\n auth_value.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()),\n Some(\"legacy-key\"),\n \"Codex provider switching should preserve the existing live auth.json\"\n );\n\n let config_text =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n assert!(\n config_text.contains(\"mcp_servers.echo-server\"),\n \"config.toml should contain synced MCP servers\"\n );\n assert!(\n config_text.contains(\"experimental_bearer_token\"),\n \"config.toml should carry the selected provider API key\"\n );\n\n let current_id = state\n .db\n .get_current_provider(AppType::Codex.as_str())\n .expect(\"read current provider after switch\");\n assert_eq!(\n current_id.as_deref(),\n Some(\"new-provider\"),\n \"current provider updated\"\n );\n\n let providers = state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"read providers after switch\");\n\n let new_provider = providers.get(\"new-provider\").expect(\"new provider exists\");\n let new_config_text = new_provider\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or_default();\n // provider 存储的是原始配置,不包含 MCP 同步后的内容\n assert!(\n new_config_text.contains(\"mcp_servers.latest\"),\n \"provider config should contain original MCP servers\"\n );\n // live 文件额外包含同步的 MCP 服务器\n assert!(\n config_text.contains(\"mcp_servers.echo-server\"),\n \"live config should include synced MCP servers\"\n );\n\n let legacy = providers\n .get(\"old-provider\")\n .expect(\"legacy provider still exists\");\n let legacy_auth_value = legacy\n .settings_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n assert_eq!(\n legacy_auth_value, \"legacy-key\",\n \"previous provider should be backfilled with live auth\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_preserves_user_model_provider_id_after_migration() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let legacy_auth = json!({ \"OPENAI_API_KEY\": \"rightcode-key\" });\n let legacy_config = r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n write_codex_live_atomic(&legacy_auth, Some(legacy_config))\n .expect(\"seed existing codex live config\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"old-provider\".to_string();\n manager.providers.insert(\n \"old-provider\".to_string(),\n Provider::with_id(\n \"old-provider\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"stale\"},\n \"config\": legacy_config\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"new-provider\".to_string(),\n Provider::with_id(\n \"new-provider\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"fresh-key\"},\n \"config\": r#\"model_provider = \"aihubmix\"\nmodel = \"gpt-5.4\"\n\n[model_providers.aihubmix]\nname = \"AiHubMix\"\nbase_url = \"https://aihubmix.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"new-provider\")\n .expect(\"switch provider should succeed\");\n\n let config_text =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n let parsed: toml::Value = toml::from_str(&config_text).expect(\"parse config.toml\");\n\n assert_eq!(\n parsed.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider switching should preserve user-editable model_provider after the one-time migration\"\n );\n\n let model_providers = parsed\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"model_providers table exists\");\n assert!(\n model_providers.get(\"custom\").is_none(),\n \"provider switching should not force user-edited provider ids back to custom\"\n );\n assert_eq!(\n model_providers\n .get(\"aihubmix\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"selected provider id should point at the newly selected supplier endpoint\"\n );\n\n let providers = state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"read providers after switch\");\n let new_config_text = providers\n .get(\"new-provider\")\n .expect(\"new provider exists\")\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or_default();\n assert!(\n new_config_text.contains(\"[model_providers.aihubmix]\"),\n \"stored provider template should remain provider-specific\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_preserves_oauth_and_backfills_api_key_from_live_token() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n enable_codex_official_auth_preservation();\n let _home = ensure_test_home();\n\n let live_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"oauth-token\",\n \"account_id\": \"acct-1\"\n }\n });\n let legacy_config = r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n write_codex_live_atomic(&live_auth, Some(legacy_config))\n .expect(\"seed existing Codex OAuth live config\");\n\n let bridge_provider = Provider::with_id(\n \"bridge-provider\".to_string(),\n \"Bridge Provider\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"bridge-key\"},\n \"config\": r#\"model_provider = \"aihubmix\"\nmodel = \"gpt-5.4\"\n\n[model_providers.aihubmix]\nname = \"AiHubMix\"\nbase_url = \"https://aihubmix.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"legacy-provider\".to_string();\n manager.providers.insert(\n \"legacy-provider\".to_string(),\n Provider::with_id(\n \"legacy-provider\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"rightcode-key\"},\n \"config\": legacy_config\n }),\n None,\n ),\n );\n manager\n .providers\n .insert(\"bridge-provider\".to_string(), bridge_provider);\n manager.providers.insert(\n \"plain-provider\".to_string(),\n Provider::with_id(\n \"plain-provider\".to_string(),\n \"Plain Provider\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"plain-key\"},\n \"config\": r#\"model_provider = \"plain\"\nmodel = \"gpt-5.4\"\n\n[model_providers.plain]\nname = \"Plain\"\nbase_url = \"https://plain.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"bridge-provider\")\n .expect(\"switch to bridge provider should succeed\");\n\n let auth_value: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n assert_eq!(\n auth_value.get(\"auth_mode\").and_then(|v| v.as_str()),\n Some(\"chatgpt\")\n );\n assert!(\n auth_value\n .get(\"OPENAI_API_KEY\")\n .is_some_and(|v| v.is_null()),\n \"provider switching should keep OPENAI_API_KEY null in live auth.json\"\n );\n assert_eq!(\n auth_value\n .pointer(\"/tokens/access_token\")\n .and_then(|v| v.as_str()),\n Some(\"oauth-token\"),\n \"existing ChatGPT OAuth token should be preserved\"\n );\n\n let live_config =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n let parsed_live: toml::Value = toml::from_str(&live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .and_then(|v| v.get(\"experimental_bearer_token\"))\n .and_then(|v| v.as_str()),\n Some(\"bridge-key\"),\n \"third-party key should be injected into the selected live provider table\"\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .and_then(|v| v.get(\"requires_openai_auth\"))\n .and_then(|v| v.as_bool()),\n Some(true)\n );\n\n ProviderService::switch(&state, AppType::Codex, \"plain-provider\")\n .expect(\"switch away should backfill bridge provider\");\n\n let providers = state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"read providers\");\n let stored_bridge = providers\n .get(\"bridge-provider\")\n .expect(\"bridge provider exists after backfill\");\n assert_eq!(\n stored_bridge\n .settings_config\n .pointer(\"/auth/OPENAI_API_KEY\")\n .and_then(|v| v.as_str()),\n Some(\"bridge-key\"),\n \"backfill should restore the API key into stored provider auth\"\n );\n assert!(\n stored_bridge\n .settings_config\n .pointer(\"/auth/tokens\")\n .is_none(),\n \"backfill should not persist ChatGPT OAuth tokens into provider storage\"\n );\n assert!(\n !stored_bridge\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or_default()\n .contains(\"experimental_bearer_token\"),\n \"stored provider config should stay clean; bridge token is generated only for live config\"\n );\n}\n\n#[tokio::test(flavor = \"current_thread\")]\n#[allow(\n clippy::await_holding_lock,\n reason = \"this integration-style test must serialize global test HOME and settings mutations across async takeover calls\"\n)]\nasync fn codex_official_to_deepseek_then_takeover_enters_and_restores_proxy_managed_live_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n enable_codex_official_auth_preservation();\n let _home = ensure_test_home();\n\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"access_token\": \"oauth-access\",\n \"id_token\": \"oauth-id\"\n }\n });\n let official_config = r#\"model_provider = \"openai\"\nmodel = \"gpt-5\"\n\n[model_providers.openai]\nname = \"OpenAI\"\nwire_api = \"responses\"\n\"#;\n write_codex_live_atomic(&oauth_auth, Some(official_config))\n .expect(\"seed official Codex OAuth live config\");\n\n let deepseek_provider_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-chat\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"official-provider\".to_string();\n\n let mut official_provider = Provider::with_id(\n \"official-provider\".to_string(),\n \"OpenAI Official\".to_string(),\n json!({\n \"auth\": oauth_auth,\n \"config\": official_config\n }),\n None,\n );\n official_provider.category = Some(\"official\".to_string());\n manager\n .providers\n .insert(\"official-provider\".to_string(), official_provider);\n\n let mut deepseek_provider = Provider::with_id(\n \"deepseek-provider\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"deepseek-key\"},\n \"config\": deepseek_provider_config\n }),\n None,\n );\n deepseek_provider.category = Some(\"custom\".to_string());\n manager\n .providers\n .insert(\"deepseek-provider\".to_string(), deepseek_provider);\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n let mut proxy_config = state.db.get_proxy_config().await.expect(\"get proxy config\");\n proxy_config.listen_port = 0;\n state\n .db\n .update_proxy_config(proxy_config)\n .await\n .expect(\"use ephemeral proxy port\");\n\n ProviderService::switch(&state, AppType::Codex, \"deepseek-provider\")\n .expect(\"switch from official subscription to DeepSeek\");\n\n let auth_after_switch: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth after switch\");\n assert_eq!(\n auth_after_switch, oauth_auth,\n \"normal provider switch with Codex preservation enabled must keep OAuth auth.json\"\n );\n\n let config_after_switch =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config\");\n assert!(\n config_after_switch.contains(\"https://api.deepseek.com/v1\"),\n \"normal switch should write the DeepSeek endpoint before takeover\"\n );\n assert!(\n config_after_switch.contains(\"deepseek-key\"),\n \"normal switch should inject the DeepSeek key into config.toml\"\n );\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"enable Codex takeover\");\n let proxy_status = state\n .proxy_service\n .get_status()\n .await\n .expect(\"read proxy status after takeover\");\n let codex_proxy_base_url = format!(\"http://127.0.0.1:{}/v1\", proxy_status.port);\n\n let auth_after_takeover: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth after takeover\");\n assert_eq!(\n auth_after_takeover, oauth_auth,\n \"enabling takeover must not rewrite Codex OAuth auth.json\"\n );\n\n let config_after_takeover =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config\");\n assert!(\n config_after_takeover.contains(&codex_proxy_base_url),\n \"enabling takeover should point Codex config.toml at the local proxy\"\n );\n assert!(\n config_after_takeover.contains(\"PROXY_MANAGED\"),\n \"enabling takeover should move the proxy placeholder into config.toml\"\n );\n assert!(\n !config_after_takeover.contains(\"https://api.deepseek.com/v1\"),\n \"takeover live config should not keep the upstream DeepSeek endpoint\"\n );\n\n let backup = state\n .db\n .get_live_backup(\"codex\")\n .await\n .expect(\"read Codex backup\")\n .expect(\"backup exists after takeover\");\n let backup_value: serde_json::Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n let backup_config = backup_value\n .get(\"config\")\n .and_then(|value| value.as_str())\n .unwrap_or_default();\n assert!(\n backup_config.contains(\"https://api.deepseek.com/v1\")\n && backup_config.contains(\"deepseek-key\"),\n \"takeover backup should remain the restorable DeepSeek config\"\n );\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n\n let restored_auth: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read restored auth\");\n assert_eq!(\n restored_auth, oauth_auth,\n \"disabling takeover should restore without replacing OAuth auth.json\"\n );\n\n let restored_config = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read restored config\");\n assert!(\n restored_config.contains(\"https://api.deepseek.com/v1\")\n && restored_config.contains(\"deepseek-key\"),\n \"disabling takeover should restore the selected DeepSeek live config\"\n );\n assert!(\n !restored_config.contains(\"PROXY_MANAGED\"),\n \"restored live config must not keep the proxy placeholder\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_default_overwrites_official_auth_when_preservation_off() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n // Intentionally do NOT enable preservation: this locks the default opt-out\n // behavior where switching to a third-party provider rewrites auth.json,\n // discarding the user's ChatGPT OAuth login. It is the dual of\n // `provider_service_switch_codex_preserves_oauth_and_backfills_api_key_from_live_token`.\n let _home = ensure_test_home();\n\n let live_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"official-oauth-token\",\n \"account_id\": \"acct-1\"\n }\n });\n let legacy_config = r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n write_codex_live_atomic(&live_auth, Some(legacy_config))\n .expect(\"seed existing Codex OAuth live config\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"legacy-provider\".to_string();\n manager.providers.insert(\n \"legacy-provider\".to_string(),\n Provider::with_id(\n \"legacy-provider\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"rightcode-key\"},\n \"config\": legacy_config\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"third-party\".to_string(),\n Provider::with_id(\n \"third-party\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"third-party-key\"},\n \"config\": r#\"model_provider = \"aihubmix\"\nmodel = \"gpt-5.4\"\n\n[model_providers.aihubmix]\nname = \"AiHubMix\"\nbase_url = \"https://aihubmix.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"third-party\")\n .expect(\"switch to third-party provider should succeed\");\n\n let auth_value: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n assert_eq!(\n auth_value.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()),\n Some(\"third-party-key\"),\n \"default (preservation off) should overwrite auth.json with the third-party API key\"\n );\n assert!(\n auth_value.pointer(\"/tokens/access_token\").is_none(),\n \"default switch must clear the official ChatGPT OAuth token from live auth.json\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_supports_official_login_provider_without_auth_write() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let live_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"official-oauth-token\",\n \"account_id\": \"acct-official\"\n }\n });\n write_codex_live_atomic(&live_auth, Some(\"\")).expect(\"seed official OAuth live config\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"legacy-provider\".to_string();\n manager.providers.insert(\n \"legacy-provider\".to_string(),\n Provider::with_id(\n \"legacy-provider\".to_string(),\n \"Legacy\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"legacy-key\"},\n \"config\": r#\"model_provider = \"legacy\"\n\n[model_providers.legacy]\nname = \"Legacy\"\nbase_url = \"https://legacy.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n ),\n );\n let mut official_provider = Provider::with_id(\n \"official-provider\".to_string(),\n \"OpenAI Official\".to_string(),\n json!({\n \"auth\": {},\n \"config\": \"\"\n }),\n None,\n );\n official_provider.category = Some(\"official\".to_string());\n manager\n .providers\n .insert(\"official-provider\".to_string(), official_provider);\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"official-provider\")\n .expect(\"switch to official provider should succeed without API key\");\n\n let auth_value: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n assert_eq!(\n auth_value.get(\"auth_mode\").and_then(|v| v.as_str()),\n Some(\"chatgpt\")\n );\n assert!(\n auth_value\n .get(\"OPENAI_API_KEY\")\n .is_some_and(|v| v.is_null()),\n \"official provider switching should keep OPENAI_API_KEY null\"\n );\n assert_eq!(\n auth_value\n .pointer(\"/tokens/access_token\")\n .and_then(|v| v.as_str()),\n Some(\"official-oauth-token\"),\n \"official provider should preserve the existing ChatGPT OAuth token\"\n );\n\n let live_config =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n assert!(\n !live_config.contains(\"experimental_bearer_token\"),\n \"official login provider has no API key to inject\"\n );\n}\n\n#[test]\nfn reapply_codex_official_live_resyncs_mcp_servers() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let live_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": { \"access_token\": \"official-oauth-token\", \"account_id\": \"acct\" }\n });\n write_codex_live_atomic(&live_auth, Some(\"\")).expect(\"seed official live auth\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n let mut official = Provider::with_id(\n \"official-provider\".to_string(),\n \"Official\".to_string(),\n json!({\n \"auth\": {\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": { \"access_token\": \"official-oauth-token\", \"account_id\": \"acct\" }\n },\n \"config\": \"\"\n }),\n None,\n );\n official.category = Some(\"official\".to_string());\n manager\n .providers\n .insert(\"official-provider\".to_string(), official);\n }\n let servers = initial_config\n .mcp\n .servers\n .get_or_insert_with(Default::default);\n servers.insert(\n \"echo-server\".into(),\n McpServer {\n id: \"echo-server\".into(),\n name: \"Echo Server\".into(),\n server: json!({\n \"type\": \"stdio\",\n \"command\": \"echo\"\n }),\n apps: McpApps {\n claude: false,\n codex: true,\n gemini: false,\n opencode: false,\n hermes: false,\n },\n description: None,\n homepage: None,\n docs: None,\n tags: Vec::new(),\n },\n );\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"official-provider\")\n .expect(\"switch to official provider\");\n let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read config.toml after switch\");\n assert!(\n live.contains(\"mcp_servers.echo-server\"),\n \"switch should sync enabled MCP servers into live\"\n );\n\n // 统一会话开关变更触发的 reapply 会整体重写 live config.toml(有意设计),\n // 写完必须重新投影 DB 里启用的 MCP,否则用户的 MCP 会静默失效。\n let reapplied =\n cc_switch_lib::reapply_current_codex_official_live(&state).expect(\"reapply official live\");\n assert!(\n reapplied,\n \"current provider is official, reapply should run\"\n );\n\n let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read config.toml after reapply\");\n assert!(\n live.contains(\"mcp_servers.echo-server\"),\n \"reapply must re-project enabled MCP servers after the full live rewrite, got: {live}\"\n );\n}\n\n/// reapply 走到 MCP 投影时 live 已按新开关状态落盘、开关事实上已生效:\n/// ① 投影失败若上抛,save_settings 会回滚开关设置,制造\"设置=旧值、\n/// live=新桶\"的会话分裂——必须降级为警告而不是失败;\n/// ② 投影必须只针对 Codex:sync_all_enabled 按 AppType::all() 顺序短路,\n/// Claude 排在 Codex 前面,损坏的 ~/.claude.json 会在轮到 Codex 之前\n/// 报错——若吞错了事,刚被整体重写清掉的 [mcp_servers] 就无人补回,\n/// Codex MCP 静默消失。这里用坏 JSON 的 ~/.claude.json 复现该场景。\n#[test]\nfn reapply_codex_official_live_projects_mcp_despite_broken_claude_json() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let live_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": { \"access_token\": \"official-oauth-token\", \"account_id\": \"acct\" }\n });\n write_codex_live_atomic(&live_auth, Some(\"\")).expect(\"seed official live auth\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n let mut official = Provider::with_id(\n \"official-provider\".to_string(),\n \"Official\".to_string(),\n json!({\n \"auth\": {\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": { \"access_token\": \"official-oauth-token\", \"account_id\": \"acct\" }\n },\n \"config\": \"\"\n }),\n None,\n );\n official.category = Some(\"official\".to_string());\n manager\n .providers\n .insert(\"official-provider\".to_string(), official);\n }\n let servers = initial_config\n .mcp\n .servers\n .get_or_insert_with(Default::default);\n servers.insert(\n \"echo-server\".into(),\n McpServer {\n id: \"echo-server\".into(),\n name: \"Echo Server\".into(),\n server: json!({\n \"type\": \"stdio\",\n \"command\": \"echo\"\n }),\n apps: McpApps {\n claude: false,\n codex: true,\n gemini: false,\n opencode: false,\n hermes: false,\n },\n description: None,\n homepage: None,\n docs: None,\n tags: Vec::new(),\n },\n );\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n // 先切换建立\"当前=官方\"的前置状态,再破坏 claude 文件,\n // 让损坏只作用于被测的 reapply 路径。\n ProviderService::switch(&state, AppType::Codex, \"official-provider\")\n .expect(\"switch to official provider\");\n\n // 破坏 ~/.claude.json:坏 JSON 能通过 should_sync_claude_mcp 门控\n // (文件存在即过),但 read_mcp_servers_map 解析必然报错。\n // 注意 codex-only 的服务器也会触发 claude 的 remove 分支读该文件。\n let claude_json = cc_switch_lib::get_claude_mcp_path();\n std::fs::write(&claude_json, \"{ not valid json\").expect(\"seed broken claude json\");\n\n let reapplied = cc_switch_lib::reapply_current_codex_official_live(&state)\n .expect(\"MCP projection failure must degrade to a warning, not fail the toggle\");\n assert!(\n reapplied,\n \"current provider is official, reapply should run\"\n );\n\n // 定向投影不碰 claude:Codex 的 MCP 投影必须完成,不能被\n // 无关应用的损坏文件阻断后静默丢失。\n let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read config.toml after reapply\");\n assert!(\n live.contains(\"mcp_servers.echo-server\"),\n \"codex MCP projection must not be blocked by a broken ~/.claude.json, got: {live}\"\n );\n\n // 顺带锁定:定向投影不应把手改坏的 ~/.claude.json 触碰或\"修复\"。\n let claude_after = std::fs::read_to_string(&claude_json).expect(\"read claude json\");\n assert_eq!(\n claude_after, \"{ not valid json\",\n \"codex-only projection must not touch claude's live file\"\n );\n}\n\n/// 切换供应商与 reapply 是同一类场景:live 整体重写后只需重投影本应用\n/// 的 MCP。历史实现走全量 sync_all_enabled + `?`,损坏的 ~/.claude.json\n/// 会让\"切 Codex\"直接报切换失败——而此时 DB is_current 与 live 都已\n/// 落盘,切换事实上成功,报错只制造分裂假象。\n#[test]\nfn switch_codex_projects_mcp_despite_broken_claude_json() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n write_codex_live_atomic(&json!({ \"OPENAI_API_KEY\": \"sk-old\" }), Some(\"\"))\n .expect(\"seed codex live\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.providers.insert(\n \"p\".to_string(),\n Provider::with_id(\n \"p\".to_string(),\n \"P\".to_string(),\n json!({\n \"auth\": { \"OPENAI_API_KEY\": \"sk-p\" },\n \"config\": \"model = \\\"gpt-5.5\\\"\\n\"\n }),\n None,\n ),\n );\n }\n let servers = config.mcp.servers.get_or_insert_with(Default::default);\n servers.insert(\n \"echo-server\".into(),\n McpServer {\n id: \"echo-server\".into(),\n name: \"Echo Server\".into(),\n server: json!({\n \"type\": \"stdio\",\n \"command\": \"echo\"\n }),\n apps: McpApps {\n claude: false,\n codex: true,\n gemini: false,\n opencode: false,\n hermes: false,\n },\n description: None,\n homepage: None,\n docs: None,\n tags: Vec::new(),\n },\n );\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n // 坏 JSON 能通过 should_sync_claude_mcp 门控(文件存在即过),\n // 但 read_mcp_servers_map 解析必然报错;codex-only 服务器也会\n // 触发 claude 的 remove 分支去读这个文件。\n let claude_json = cc_switch_lib::get_claude_mcp_path();\n std::fs::write(&claude_json, \"{ not valid json\").expect(\"seed broken claude json\");\n\n ProviderService::switch(&state, AppType::Codex, \"p\")\n .expect(\"broken ~/.claude.json must not fail an unrelated codex switch\");\n\n let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read config.toml after switch\");\n assert!(\n live.contains(\"mcp_servers.echo-server\"),\n \"switch must re-project codex MCP after the full live rewrite, got: {live}\"\n );\n\n let claude_after = std::fs::read_to_string(&claude_json).expect(\"read claude json\");\n assert_eq!(\n claude_after, \"{ not valid json\",\n \"codex-only projection must not touch claude's live file\"\n );\n}\n\n/// sync_all_enabled 的全量语义(配置导入 / 云同步恢复):单个应用的\n/// live 损坏不阻断其余应用的投影,但失败必须聚合上报——调用方需要\n/// 知道结果不完整。历史实现按 AppType::all() 顺序 `?` 短路,Claude\n/// 排在 Codex 前面,一份坏 ~/.claude.json 会让所有后续应用的 MCP\n/// 状态永远陈旧。\n#[test]\nfn sync_all_enabled_reports_broken_app_but_projects_the_rest() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n write_codex_live_atomic(&json!({ \"OPENAI_API_KEY\": \"sk\" }), Some(\"\")).expect(\"seed codex live\");\n\n let mut config = MultiAppConfig::default();\n let servers = config.mcp.servers.get_or_insert_with(Default::default);\n servers.insert(\n \"echo-server\".into(),\n McpServer {\n id: \"echo-server\".into(),\n name: \"Echo Server\".into(),\n server: json!({\n \"type\": \"stdio\",\n \"command\": \"echo\"\n }),\n apps: McpApps {\n claude: false,\n codex: true,\n gemini: false,\n opencode: false,\n hermes: false,\n },\n description: None,\n homepage: None,\n docs: None,\n tags: Vec::new(),\n },\n );\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n let claude_json = cc_switch_lib::get_claude_mcp_path();\n std::fs::write(&claude_json, \"{ not valid json\").expect(\"seed broken claude json\");\n\n let err = cc_switch_lib::McpService::sync_all_enabled(&state)\n .expect_err(\"broken claude live must surface as an aggregated error\");\n let message = err.to_string();\n assert!(\n message.contains(\"claude\"),\n \"aggregated error should name the failing app, got: {message}\"\n );\n\n // Claude 的失败不能阻断 Codex:best-effort 必须继续投影其余应用。\n let live = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read config.toml after sync_all_enabled\");\n assert!(\n live.contains(\"mcp_servers.echo-server\"),\n \"codex projection must proceed despite the broken claude file, got: {live}\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_official_accounts_write_auth_json() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let live_auth_a = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"official-a-live-token\",\n \"account_id\": \"acct-a\"\n }\n });\n write_codex_live_atomic(&live_auth_a, Some(\"\")).expect(\"seed official account A live auth\");\n\n let mut official_a = Provider::with_id(\n \"official-a\".to_string(),\n \"Official A\".to_string(),\n json!({\n \"auth\": {\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"stale-a-token\",\n \"account_id\": \"acct-a\"\n }\n },\n \"config\": \"\"\n }),\n None,\n );\n official_a.category = Some(\"official\".to_string());\n\n let mut official_b = Provider::with_id(\n \"official-b\".to_string(),\n \"Official B\".to_string(),\n json!({\n \"auth\": {\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"official-b-token\",\n \"account_id\": \"acct-b\"\n }\n },\n \"config\": \"\"\n }),\n None,\n );\n official_b.category = Some(\"official\".to_string());\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"official-a\".to_string();\n manager\n .providers\n .insert(\"official-a\".to_string(), official_a);\n manager\n .providers\n .insert(\"official-b\".to_string(), official_b);\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"official-b\")\n .expect(\"switch to official account B should write auth.json\");\n let auth_b: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth B\");\n assert_eq!(\n auth_b\n .pointer(\"/tokens/access_token\")\n .and_then(|v| v.as_str()),\n Some(\"official-b-token\"),\n \"switching official accounts must replace auth.json with the selected account\"\n );\n\n ProviderService::switch(&state, AppType::Codex, \"official-a\")\n .expect(\"switch back to official account A should use backfilled live auth\");\n let auth_a: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth A\");\n assert_eq!(\n auth_a\n .pointer(\"/tokens/access_token\")\n .and_then(|v| v.as_str()),\n Some(\"official-a-live-token\"),\n \"backfill should preserve account A's latest live token for later official switches\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_backfill_keeps_provider_specific_model_provider_id() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let legacy_auth = json!({ \"OPENAI_API_KEY\": \"rightcode-key\" });\n let provider_a_config = r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n write_codex_live_atomic(&legacy_auth, Some(provider_a_config))\n .expect(\"seed existing codex live config\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"provider-a\".to_string();\n manager.providers.insert(\n \"provider-a\".to_string(),\n Provider::with_id(\n \"provider-a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"rightcode-key\"},\n \"config\": provider_a_config\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"provider-b\".to_string(),\n Provider::with_id(\n \"provider-b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"aihubmix-key\"},\n \"config\": r#\"model_provider = \"aihubmix\"\nmodel = \"gpt-5.4\"\nprofile = \"work\"\n\n[model_providers.aihubmix]\nname = \"AiHubMix\"\nbase_url = \"https://aihubmix.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\n[profiles.work]\nmodel_provider = \"aihubmix\"\nmodel = \"gpt-5.4\"\n\"#\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"provider-c\".to_string(),\n Provider::with_id(\n \"provider-c\".to_string(),\n \"Vendor C\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"vendor-c-key\"},\n \"config\": r#\"model_provider = \"vendor_c\"\nmodel = \"gpt-5.4\"\n\n[model_providers.vendor_c]\nname = \"Vendor C\"\nbase_url = \"https://vendor-c.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"provider-b\")\n .expect(\"switch to provider b should succeed\");\n ProviderService::switch(&state, AppType::Codex, \"provider-c\")\n .expect(\"switch to provider c should succeed\");\n\n let providers = state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"read providers after switches\");\n let provider_b_config = providers\n .get(\"provider-b\")\n .expect(\"provider b exists\")\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"provider b config\");\n let parsed: toml::Value = toml::from_str(provider_b_config).expect(\"parse provider b config\");\n\n assert_eq!(\n parsed.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"backfill should restore provider b's storage-specific model_provider id\"\n );\n assert!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .is_some(),\n \"provider b should keep its own model_providers table after backfill\"\n );\n assert_eq!(\n parsed\n .get(\"profiles\")\n .and_then(|v| v.get(\"work\"))\n .and_then(|v| v.get(\"model_provider\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"profile overrides should be restored to provider b's storage-specific id\"\n );\n}\n\n#[test]\nfn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"current-provider\".to_string();\n\n let mut provider = Provider::with_id(\n \"current-provider\".to_string(),\n \"Current\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"real-token\",\n \"ANTHROPIC_BASE_URL\": \"https://claude.example\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n manager\n .providers\n .insert(\"current-provider\".to_string(), provider);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Claude.as_str(),\n Some(r#\"{ \"includeCoAuthoredBy\": false }\"#.to_string()),\n )\n .expect(\"set common config snippet\");\n\n let taken_over_live = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:5000\",\n \"ANTHROPIC_AUTH_TOKEN\": \"PROXY_MANAGED\"\n }\n });\n let settings_path = get_claude_settings_path();\n std::fs::create_dir_all(settings_path.parent().expect(\"settings dir\")).expect(\"create dir\");\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&taken_over_live).expect(\"serialize taken over live\"),\n )\n .expect(\"write taken over live\");\n\n futures::executor::block_on(state.db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\"))\n .expect(\"seed live backup\");\n\n let mut proxy_config = futures::executor::block_on(state.db.get_proxy_config_for_app(\"claude\"))\n .expect(\"get proxy config\");\n proxy_config.enabled = true;\n futures::executor::block_on(state.db.update_proxy_config_for_app(proxy_config))\n .expect(\"enable takeover\");\n\n ProviderService::sync_current_provider_for_app(&state, AppType::Claude)\n .expect(\"sync current provider should succeed\");\n\n let live_after: serde_json::Value =\n read_json_file(&settings_path).expect(\"read live settings after sync\");\n assert_eq!(\n live_after, taken_over_live,\n \"sync should not overwrite live config while takeover is active\"\n );\n\n let backup = futures::executor::block_on(state.db.get_live_backup(\"claude\"))\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let backup_value: serde_json::Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup value\");\n\n assert_eq!(\n backup_value\n .get(\"includeCoAuthoredBy\")\n .and_then(|v| v.as_bool()),\n Some(false),\n \"restore backup should receive the updated effective config\"\n );\n assert_eq!(\n backup_value\n .get(\"env\")\n .and_then(|v| v.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|v| v.as_str()),\n Some(\"real-token\"),\n \"restore backup should preserve the provider token rather than proxy placeholder\"\n );\n}\n\n#[test]\nfn switch_codex_provider_with_takeover_live_but_stopped_proxy_keeps_proxy_live_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n enable_codex_official_auth_preservation();\n let _home = ensure_test_home();\n\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"access_token\": \"oauth-access\",\n \"id_token\": \"oauth-id\"\n }\n });\n let old_provider_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-chat\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"old-key\"\n\"#;\n let proxy_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-chat\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"PROXY_MANAGED\"\n\"#;\n write_codex_live_atomic(&oauth_auth, Some(proxy_live_config))\n .expect(\"seed taken-over Codex live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"old-provider\".to_string();\n\n let mut old_provider = Provider::with_id(\n \"old-provider\".to_string(),\n \"DeepSeek Old\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"old-key\"},\n \"config\": old_provider_config\n }),\n None,\n );\n old_provider.category = Some(\"custom\".to_string());\n manager\n .providers\n .insert(\"old-provider\".to_string(), old_provider);\n\n let mut new_provider = Provider::with_id(\n \"new-provider\".to_string(),\n \"DeepSeek New\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"new-key\"},\n \"config\": r#\"model_provider = \"deepseek-new\"\nmodel = \"deepseek-reasoner\"\n\n[model_providers.deepseek-new]\nname = \"DeepSeek New\"\nbase_url = \"https://new.deepseek.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n new_provider.category = Some(\"custom\".to_string());\n manager\n .providers\n .insert(\"new-provider\".to_string(), new_provider);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n futures::executor::block_on(\n state.db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": oauth_auth,\n \"config\": old_provider_config\n }))\n .expect(\"serialize backup\"),\n ),\n )\n .expect(\"seed Codex live backup\");\n\n assert!(\n !futures::executor::block_on(state.proxy_service.is_running()),\n \"fixture keeps the proxy server stopped\"\n );\n\n ProviderService::switch(&state, AppType::Codex, \"new-provider\")\n .expect(\"switch should update takeover backup instead of writing normal live config\");\n\n let auth_after: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n assert_eq!(\n auth_after, oauth_auth,\n \"provider switch during takeover ownership must not rewrite Codex OAuth auth\"\n );\n\n let live_config =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n assert!(\n live_config.contains(\"http://127.0.0.1:15721/v1\"),\n \"live config should remain pointed at the local proxy\"\n );\n assert!(\n live_config.contains(\"PROXY_MANAGED\"),\n \"live config should keep the proxy bearer placeholder\"\n );\n assert!(\n live_config.contains(r#\"model_provider = \"deepseek-new\"\"#)\n && live_config.contains(r#\"name = \"DeepSeek New\"\"#),\n \"live config should update the Codex-visible provider label during takeover\"\n );\n assert!(\n !live_config.contains(\"https://new.deepseek.example/v1\"),\n \"normal provider base_url must not overwrite taken-over live config\"\n );\n\n let backup = futures::executor::block_on(state.db.get_live_backup(\"codex\"))\n .expect(\"get Codex backup\")\n .expect(\"backup exists\");\n let backup_value: serde_json::Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n assert_eq!(\n backup_value.get(\"auth\"),\n Some(&auth_after),\n \"restore backup should preserve the official OAuth auth\"\n );\n let backup_config = backup_value\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or_default();\n assert!(\n backup_config.contains(\"new-key\") && backup_config.contains(\"deepseek-new\"),\n \"restore backup should be rebuilt from the newly selected provider\"\n );\n\n let current = state\n .db\n .get_current_provider(AppType::Codex.as_str())\n .expect(\"get current provider\");\n assert_eq!(current.as_deref(), Some(\"new-provider\"));\n}\n\n#[test]\nfn explicitly_cleared_common_snippet_is_not_auto_extracted() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let state = create_test_state().expect(\"create test state\");\n state\n .db\n .set_config_snippet_cleared(AppType::Claude.as_str(), true)\n .expect(\"mark snippet explicitly cleared\");\n\n assert!(\n !state\n .db\n .should_auto_extract_config_snippet(AppType::Claude.as_str())\n .expect(\"check auto-extract eligibility\"),\n \"explicitly cleared snippets should block auto-extraction\"\n );\n\n state\n .db\n .set_config_snippet(AppType::Claude.as_str(), Some(\"{}\".to_string()))\n .expect(\"set snippet\");\n state\n .db\n .set_config_snippet_cleared(AppType::Claude.as_str(), false)\n .expect(\"clear explicit-empty marker\");\n\n assert!(\n !state\n .db\n .should_auto_extract_config_snippet(AppType::Claude.as_str())\n .expect(\"check auto-extract after snippet saved\"),\n \"existing snippets should also block auto-extraction\"\n );\n}\n\n#[test]\nfn legacy_common_config_migration_flag_roundtrip() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let state = create_test_state().expect(\"create test state\");\n\n assert!(\n !state\n .db\n .is_legacy_common_config_migrated()\n .expect(\"initial migration flag\"),\n \"migration flag should default to false\"\n );\n\n state\n .db\n .set_legacy_common_config_migrated(true)\n .expect(\"set migration flag\");\n assert!(\n state\n .db\n .is_legacy_common_config_migrated()\n .expect(\"read migration flag\"),\n \"migration flag should persist once set\"\n );\n\n state\n .db\n .set_legacy_common_config_migrated(false)\n .expect(\"clear migration flag\");\n assert!(\n !state\n .db\n .is_legacy_common_config_migrated()\n .expect(\"read migration flag after clear\"),\n \"migration flag should be removable for tests/debugging\"\n );\n}\n\n#[test]\nfn switch_packycode_gemini_updates_security_selected_type() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Gemini)\n .expect(\"gemini manager\");\n manager.current = \"packy-gemini\".to_string();\n manager.providers.insert(\n \"packy-gemini\".to_string(),\n Provider::with_id(\n \"packy-gemini\".to_string(),\n \"PackyCode\".to_string(),\n json!({\n \"env\": {\n \"GEMINI_API_KEY\": \"pk-key\",\n \"GOOGLE_GEMINI_BASE_URL\": \"https://www.packyapi.com\"\n }\n }),\n Some(\"https://www.packyapi.com\".to_string()),\n ),\n );\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Gemini, \"packy-gemini\")\n .expect(\"switching to PackyCode Gemini should succeed\");\n\n // Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json\n let settings_path = home.join(\".gemini\").join(\"settings.json\");\n assert!(\n settings_path.exists(),\n \"Gemini settings.json should exist at {}\",\n settings_path.display()\n );\n let raw = std::fs::read_to_string(&settings_path).expect(\"read gemini settings.json\");\n let value: serde_json::Value =\n serde_json::from_str(&raw).expect(\"parse gemini settings.json after switch\");\n\n assert_eq!(\n value\n .pointer(\"/security/auth/selectedType\")\n .and_then(|v| v.as_str()),\n Some(\"gemini-api-key\"),\n \"PackyCode Gemini should set security.auth.selectedType\"\n );\n}\n\n#[test]\nfn packycode_partner_meta_triggers_security_flag_even_without_keywords() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Gemini)\n .expect(\"gemini manager\");\n manager.current = \"packy-meta\".to_string();\n let mut provider = Provider::with_id(\n \"packy-meta\".to_string(),\n \"Generic Gemini\".to_string(),\n json!({\n \"env\": {\n \"GEMINI_API_KEY\": \"pk-meta\",\n \"GOOGLE_GEMINI_BASE_URL\": \"https://generativelanguage.googleapis.com\"\n }\n }),\n Some(\"https://example.com\".to_string()),\n );\n provider.meta = Some(ProviderMeta {\n partner_promotion_key: Some(\"packycode\".to_string()),\n ..ProviderMeta::default()\n });\n manager.providers.insert(\"packy-meta\".to_string(), provider);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Gemini, \"packy-meta\")\n .expect(\"switching to partner meta provider should succeed\");\n\n // Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json\n let settings_path = home.join(\".gemini\").join(\"settings.json\");\n assert!(\n settings_path.exists(),\n \"Gemini settings.json should exist at {}\",\n settings_path.display()\n );\n let raw = std::fs::read_to_string(&settings_path).expect(\"read gemini settings.json\");\n let value: serde_json::Value =\n serde_json::from_str(&raw).expect(\"parse gemini settings.json after switch\");\n\n assert_eq!(\n value\n .pointer(\"/security/auth/selectedType\")\n .and_then(|v| v.as_str()),\n Some(\"gemini-api-key\"),\n \"Partner meta should set security.auth.selectedType even without packy keywords\"\n );\n}\n\n#[test]\nfn switch_google_official_gemini_preserves_env_vars() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Gemini)\n .expect(\"gemini manager\");\n manager.current = \"google-official\".to_string();\n let mut provider = Provider::with_id(\n \"google-official\".to_string(),\n \"Google\".to_string(),\n json!({\n \"env\": {\n \"GEMINI_MODEL\": \"gemini-2.5-pro\"\n }\n }),\n Some(\"https://ai.google.dev\".to_string()),\n );\n provider.meta = Some(ProviderMeta {\n partner_promotion_key: Some(\"google-official\".to_string()),\n ..ProviderMeta::default()\n });\n manager\n .providers\n .insert(\"google-official\".to_string(), provider);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Gemini, \"google-official\")\n .expect(\"switching to Google official Gemini should succeed\");\n\n // Verify env vars are preserved in ~/.gemini/.env\n let env_path = home.join(\".gemini\").join(\".env\");\n assert!(\n env_path.exists(),\n \"Gemini .env should exist at {}\",\n env_path.display()\n );\n let env_content = std::fs::read_to_string(&env_path).expect(\"read gemini .env\");\n assert!(\n env_content.contains(\"GEMINI_MODEL=gemini-2.5-pro\"),\n \"GEMINI_MODEL should be preserved in .env, got: {env_content}\"\n );\n\n // Verify OAuth security flag is still set correctly\n let gemini_settings = home.join(\".gemini\").join(\"settings.json\");\n let gemini_raw = std::fs::read_to_string(&gemini_settings).expect(\"read gemini settings\");\n let gemini_value: serde_json::Value =\n serde_json::from_str(&gemini_raw).expect(\"parse gemini settings\");\n assert_eq!(\n gemini_value\n .pointer(\"/security/auth/selectedType\")\n .and_then(|v| v.as_str()),\n Some(\"oauth-personal\"),\n \"OAuth security flag should still be set\"\n );\n}\n\n#[test]\nfn provider_service_switch_claude_updates_live_and_state() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let settings_path = get_claude_settings_path();\n if let Some(parent) = settings_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n }\n let legacy_live = json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"legacy-key\"\n },\n \"workspace\": {\n \"path\": \"/tmp/workspace\"\n }\n });\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&legacy_live).expect(\"serialize legacy live\"),\n )\n .expect(\"seed claude live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"old-provider\".to_string();\n manager.providers.insert(\n \"old-provider\".to_string(),\n Provider::with_id(\n \"old-provider\".to_string(),\n \"Legacy Claude\".to_string(),\n json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"stale-key\" }\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"new-provider\".to_string(),\n Provider::with_id(\n \"new-provider\".to_string(),\n \"Fresh Claude\".to_string(),\n json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"fresh-key\" },\n \"workspace\": { \"path\": \"/tmp/new-workspace\" }\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Claude, \"new-provider\")\n .expect(\"switch provider should succeed\");\n\n let live_after: serde_json::Value =\n read_json_file(&settings_path).expect(\"read claude live settings\");\n assert_eq!(\n live_after\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .and_then(|key| key.as_str()),\n Some(\"fresh-key\"),\n \"live settings.json should reflect new provider auth\"\n );\n\n let providers = state\n .db\n .get_all_providers(AppType::Claude.as_str())\n .expect(\"get all providers\");\n let current_id = state\n .db\n .get_current_provider(AppType::Claude.as_str())\n .expect(\"get current provider\");\n assert_eq!(\n current_id.as_deref(),\n Some(\"new-provider\"),\n \"current provider updated\"\n );\n\n let legacy_provider = providers\n .get(\"old-provider\")\n .expect(\"legacy provider still exists\");\n assert_eq!(\n legacy_provider.settings_config, legacy_live,\n \"previous provider should receive backfilled live config\"\n );\n}\n\n/// 切走勾选了通用配置的 Claude 供应商时,应把它 live 里新增的可共享键\n/// (用户直接在应用内装插件/改偏好)捕获进通用配置片段,并带到下一个供应商。\n#[test]\nfn switch_claude_syncs_new_shared_keys_from_live_into_common_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let settings_path = get_claude_settings_path();\n if let Some(parent) = settings_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n }\n // A 的 live = A 私有密钥(含非 Anthropic 的 OpenRouter 凭据)+ 已共享的 theme\n // + 用户刚在应用内新增的 enableAllProjectMcpServers\n let live = json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\", \"OPENROUTER_API_KEY\": \"sk-or-leak\" },\n \"theme\": \"dark\",\n \"enableAllProjectMcpServers\": true\n });\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&live).expect(\"serialize live\"),\n )\n .expect(\"seed claude live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"a\".to_string();\n let mut provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" } }),\n None,\n );\n provider_a.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"a\".to_string(), provider_a);\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"b-key\" } }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"b\".to_string(), provider_b);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Claude.as_str(),\n Some(r#\"{\"theme\":\"dark\"}\"#.to_string()),\n )\n .expect(\"seed common config snippet\");\n\n ProviderService::switch(&state, AppType::Claude, \"b\").expect(\"switch should succeed\");\n\n // 片段应捕获到新增键,并保留已有共享键,且绝不含密钥\n let snippet = state\n .db\n .get_config_snippet(AppType::Claude.as_str())\n .expect(\"read snippet\")\n .expect(\"snippet present\");\n let snippet_value: serde_json::Value =\n serde_json::from_str(&snippet).expect(\"snippet is valid JSON\");\n assert_eq!(\n snippet_value.get(\"enableAllProjectMcpServers\"),\n Some(&json!(true)),\n \"newly added shared key should be captured into common config\"\n );\n assert_eq!(\n snippet_value.get(\"theme\").and_then(|v| v.as_str()),\n Some(\"dark\"),\n \"previously shared key should be preserved\"\n );\n assert!(\n snippet_value\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"secrets must never leak into the shared snippet\"\n );\n assert!(\n snippet_value\n .get(\"env\")\n .and_then(|env| env.get(\"OPENROUTER_API_KEY\"))\n .is_none(),\n \"non-Anthropic Claude credentials must never leak into the shared snippet\"\n );\n\n // 新增键应通过通用配置带到 B 的 live\n let live_after: serde_json::Value =\n read_json_file(&settings_path).expect(\"read live after switch\");\n assert_eq!(\n live_after.get(\"enableAllProjectMcpServers\"),\n Some(&json!(true)),\n \"shared key should propagate to the next provider's live config\"\n );\n assert!(\n live_after\n .get(\"env\")\n .and_then(|env| env.get(\"OPENROUTER_API_KEY\"))\n .is_none(),\n \"leaked credential must not be injected into the next provider's live\"\n );\n assert_eq!(\n live_after\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"b-key\"),\n \"live should reflect new provider's own auth\"\n );\n}\n\n/// 用户在应用内删掉一个已共享的键后,切换应把删除同步进通用配置,\n/// 且不会在切到下一个供应商时被重新注入(否则会\"删不掉\")。\n#[test]\nfn switch_claude_syncs_deletions_from_live_into_common_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let settings_path = get_claude_settings_path();\n if let Some(parent) = settings_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n }\n // live 里 theme 还在,但用户已删掉 enableAllProjectMcpServers\n let live = json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" },\n \"theme\": \"dark\"\n });\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&live).expect(\"serialize live\"),\n )\n .expect(\"seed claude live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"a\".to_string();\n let mut provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" } }),\n None,\n );\n provider_a.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"a\".to_string(), provider_a);\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"b-key\" } }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"b\".to_string(), provider_b);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n // 片段里仍残留 enableAllProjectMcpServers(上次共享的)\n state\n .db\n .set_config_snippet(\n AppType::Claude.as_str(),\n Some(r#\"{\"theme\":\"dark\",\"enableAllProjectMcpServers\":true}\"#.to_string()),\n )\n .expect(\"seed common config snippet\");\n\n ProviderService::switch(&state, AppType::Claude, \"b\").expect(\"switch should succeed\");\n\n let snippet = state\n .db\n .get_config_snippet(AppType::Claude.as_str())\n .expect(\"read snippet\")\n .expect(\"snippet present\");\n let snippet_value: serde_json::Value =\n serde_json::from_str(&snippet).expect(\"snippet is valid JSON\");\n assert!(\n snippet_value.get(\"enableAllProjectMcpServers\").is_none(),\n \"deleted key should be removed from common config\"\n );\n assert_eq!(\n snippet_value.get(\"theme\").and_then(|v| v.as_str()),\n Some(\"dark\"),\n \"untouched shared key should remain\"\n );\n\n // 切到 B 后 live 不应再出现被删除的键\n let live_after: serde_json::Value =\n read_json_file(&settings_path).expect(\"read live after switch\");\n assert!(\n live_after.get(\"enableAllProjectMcpServers\").is_none(),\n \"deleted shared key must not be re-injected into the next provider\"\n );\n}\n\n/// Codex 版切换自动回写:live 里新增的共享键被捕获进通用配置片段并传递给\n/// 下一个供应商;供应商专属字段、密钥与 cc-switch 注入产物绝不进片段;\n/// 回填后旧供应商的存储配置不残留片段内容(autosync 先于 strip,值必然匹配)。\n#[test]\nfn switch_codex_syncs_shared_keys_from_live_into_common_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n // A 激活状态下的 live:A 专属路由 + 已共享的 [tui] + 用户刚加的\n // disable_response_storage + cc-switch 注入产物 + MCP 同步投影\n // + 顶层 wire_api(无 model_provider 时的 fallback 写法,属 A 的路由语义)\n // + 历史错误格式 [mcp.servers](sync_all_enabled 清不掉的孤儿形态)\n let live_config = r#\"model = \"gpt-5.5\"\nmodel_provider = \"aprov\"\nwire_api = \"chat\"\nexperimental_bearer_token = \"sk-a-live-secret\"\nmodel_catalog_json = \"cc-switch-model-catalog.json\"\nweb_search = \"disabled\"\ndisable_response_storage = true\n\n[tui]\nnotifications = true\n\n[model_providers.aprov]\nname = \"A Prov\"\nbase_url = \"https://a.example/v1\"\nwire_api = \"responses\"\n\n[mcp_servers.echo]\ntype = \"stdio\"\ncommand = \"echo\"\n\n[mcp.servers.ghost-legacy]\ncommand = \"ghost-cmd\"\n\"#;\n write_codex_live_atomic(&json!({ \"OPENAI_API_KEY\": \"sk-a\" }), Some(live_config))\n .expect(\"seed codex live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"a\".to_string();\n let mut provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"auth\": { \"OPENAI_API_KEY\": \"sk-a\" },\n \"config\": \"model = \\\"gpt-5.5\\\"\\nmodel_provider = \\\"aprov\\\"\\n\\n[model_providers.aprov]\\nname = \\\"A Prov\\\"\\nbase_url = \\\"https://a.example/v1\\\"\\nwire_api = \\\"responses\\\"\\n\"\n }),\n None,\n );\n provider_a.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"a\".to_string(), provider_a);\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"auth\": { \"OPENAI_API_KEY\": \"sk-b\" },\n \"config\": \"model = \\\"gpt-5.5\\\"\\nmodel_provider = \\\"bprov\\\"\\n\\n[model_providers.bprov]\\nname = \\\"B Prov\\\"\\nbase_url = \\\"https://b.example/v1\\\"\\nwire_api = \\\"responses\\\"\\n\"\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"b\".to_string(), provider_b);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Codex.as_str(),\n Some(\"[tui]\\nnotifications = true\\n\".to_string()),\n )\n .expect(\"seed codex common config snippet\");\n\n ProviderService::switch(&state, AppType::Codex, \"b\").expect(\"switch should succeed\");\n\n // 片段:捕获新增共享键、保留既有共享键;专属字段/密钥/注入产物一律不进\n let snippet = state\n .db\n .get_config_snippet(AppType::Codex.as_str())\n .expect(\"read snippet\")\n .expect(\"snippet present\");\n assert!(\n snippet.contains(\"disable_response_storage = true\"),\n \"newly added shared key should be captured, got: {snippet}\"\n );\n assert!(\n snippet.contains(\"notifications = true\"),\n \"previously shared key should be preserved, got: {snippet}\"\n );\n for forbidden in [\n \"experimental_bearer_token\",\n \"sk-a-live-secret\",\n \"model_catalog_json\",\n \"web_search\",\n \"mcp_servers\",\n \"model_providers\",\n \"model_provider\",\n \"wire_api\",\n \"ghost-legacy\",\n ] {\n assert!(\n !snippet.contains(forbidden),\n \"'{forbidden}' must never enter the shared snippet, got: {snippet}\"\n );\n }\n\n // B 的 live:共享键传递到位,A 的密钥/投影不得跟过来\n let live_after = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read config.toml after switch\");\n assert!(\n live_after.contains(\"disable_response_storage = true\"),\n \"shared key should propagate to the next provider's live, got: {live_after}\"\n );\n assert!(\n live_after.contains(\"model_provider = \\\"bprov\\\"\"),\n \"live should be provider B's own config, got: {live_after}\"\n );\n assert!(\n !live_after.contains(\"sk-a-live-secret\"),\n \"provider A's bearer token must not leak into B's live, got: {live_after}\"\n );\n assert!(\n !live_after.contains(\"mcp_servers\"),\n \"no DB-enabled MCP servers, so live must not resurrect stale entries, got: {live_after}\"\n );\n assert!(\n !live_after.contains(\"ghost-legacy\"),\n \"the legacy [mcp.servers] orphan must not propagate to B's live, got: {live_after}\"\n );\n assert!(\n !live_after.contains(\"wire_api = \\\"chat\\\"\"),\n \"provider A's top-level wire_api must not rewrite B's protocol, got: {live_after}\"\n );\n\n // A 的存储配置:回填后不残留片段内容 / MCP 投影 / 注入产物\n let providers = state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"read providers after switch\");\n let stored_a = providers.get(\"a\").expect(\"provider a exists\");\n let stored_a_config = stored_a\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or_default();\n assert!(\n stored_a_config.contains(\"model_provider = \\\"aprov\\\"\"),\n \"provider-owned routing must survive backfill, got: {stored_a_config}\"\n );\n // 顶层 wire_api 是 A 自己的路由语义:不进片段,但回填时留在 A 的快照里\n assert!(\n stored_a_config.contains(\"wire_api = \\\"chat\\\"\"),\n \"provider-owned top-level wire_api must survive backfill, got: {stored_a_config}\"\n );\n for forbidden in [\n \"disable_response_storage\",\n \"notifications\",\n \"mcp_servers\",\n \"experimental_bearer_token\",\n \"ghost-legacy\",\n ] {\n assert!(\n !stored_a_config.contains(forbidden),\n \"'{forbidden}' must be stripped from the stored provider config on backfill, got: {stored_a_config}\"\n );\n }\n}\n\n/// Codex 版删除同步:用户在 live 里删掉一个已共享的键后,切换应把删除\n/// 同步进通用配置,且不会在切到下一个供应商时被重新注入(否则\"删不掉\")。\n#[test]\nfn switch_codex_syncs_deletions_from_live_into_common_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n // 片段里有两个共享键,但用户已在 live 里删掉 disable_response_storage\n let live_config = r#\"model_provider = \"aprov\"\n\n[tui]\nnotifications = true\n\n[model_providers.aprov]\nname = \"A Prov\"\nbase_url = \"https://a.example/v1\"\nwire_api = \"responses\"\n\"#;\n write_codex_live_atomic(&json!({ \"OPENAI_API_KEY\": \"sk-a\" }), Some(live_config))\n .expect(\"seed codex live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"a\".to_string();\n for (id, name, prov_key) in [(\"a\", \"A\", \"aprov\"), (\"b\", \"B\", \"bprov\")] {\n let mut provider = Provider::with_id(\n id.to_string(),\n name.to_string(),\n json!({\n \"auth\": { \"OPENAI_API_KEY\": format!(\"sk-{id}\") },\n \"config\": format!(\"model_provider = \\\"{prov_key}\\\"\\n\\n[model_providers.{prov_key}]\\nname = \\\"{name} Prov\\\"\\nbase_url = \\\"https://{id}.example/v1\\\"\\nwire_api = \\\"responses\\\"\\n\")\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(id.to_string(), provider);\n }\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Codex.as_str(),\n Some(\"disable_response_storage = true\\n\\n[tui]\\nnotifications = true\\n\".to_string()),\n )\n .expect(\"seed codex common config snippet\");\n\n ProviderService::switch(&state, AppType::Codex, \"b\").expect(\"switch should succeed\");\n\n let snippet = state\n .db\n .get_config_snippet(AppType::Codex.as_str())\n .expect(\"read snippet\")\n .expect(\"snippet present\");\n assert!(\n !snippet.contains(\"disable_response_storage\"),\n \"deleted shared key must be removed from the snippet, got: {snippet}\"\n );\n assert!(\n snippet.contains(\"notifications = true\"),\n \"kept shared key should remain in the snippet, got: {snippet}\"\n );\n\n let live_after = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read config.toml after switch\");\n assert!(\n !live_after.contains(\"disable_response_storage\"),\n \"deleted shared key must not be re-injected into the next provider, got: {live_after}\"\n );\n assert!(\n live_after.contains(\"notifications = true\"),\n \"kept shared key should propagate to the next provider, got: {live_after}\"\n );\n}\n\n/// 未勾选\"写入通用配置\"的供应商,其 live 改动不应自动污染通用配置片段。\n#[test]\nfn switch_claude_does_not_sync_common_config_for_opted_out_provider() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let settings_path = get_claude_settings_path();\n if let Some(parent) = settings_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n }\n let live = json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" },\n \"providerSpecific\": \"x\"\n });\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&live).expect(\"serialize live\"),\n )\n .expect(\"seed claude live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"a\".to_string();\n // A 未勾选通用配置(meta = None)\n manager.providers.insert(\n \"a\".to_string(),\n Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" } }),\n None,\n ),\n );\n manager.providers.insert(\n \"b\".to_string(),\n Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"b-key\" } }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Claude.as_str(),\n Some(r#\"{\"theme\":\"dark\"}\"#.to_string()),\n )\n .expect(\"seed common config snippet\");\n\n ProviderService::switch(&state, AppType::Claude, \"b\").expect(\"switch should succeed\");\n\n let snippet = state\n .db\n .get_config_snippet(AppType::Claude.as_str())\n .expect(\"read snippet\")\n .expect(\"snippet present\");\n let snippet_value: serde_json::Value =\n serde_json::from_str(&snippet).expect(\"snippet is valid JSON\");\n assert!(\n snippet_value.get(\"providerSpecific\").is_none(),\n \"opted-out provider's live changes must not pollute the shared snippet\"\n );\n assert_eq!(\n snippet_value.get(\"theme\").and_then(|v| v.as_str()),\n Some(\"dark\"),\n \"snippet should stay unchanged for opted-out providers\"\n );\n}\n\n/// 用户显式清空过通用配置(_cleared)后,切换不应把片段重新塞回来。\n#[test]\nfn switch_claude_respects_explicitly_cleared_common_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let settings_path = get_claude_settings_path();\n if let Some(parent) = settings_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n }\n let live = json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" },\n \"theme\": \"dark\"\n });\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&live).expect(\"serialize live\"),\n )\n .expect(\"seed claude live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"a\".to_string();\n let mut provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" } }),\n None,\n );\n provider_a.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"a\".to_string(), provider_a);\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"b-key\" } }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"b\".to_string(), provider_b);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet_cleared(AppType::Claude.as_str(), true)\n .expect(\"mark snippet cleared\");\n\n ProviderService::switch(&state, AppType::Claude, \"b\").expect(\"switch should succeed\");\n\n assert!(\n state\n .db\n .get_config_snippet(AppType::Claude.as_str())\n .expect(\"read snippet\")\n .is_none(),\n \"explicitly cleared snippet must not be resurrected by switch-away sync\"\n );\n}\n\n#[test]\nfn provider_service_switch_missing_provider_returns_error() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let state = create_test_state().expect(\"create test state\");\n\n let err = ProviderService::switch(&state, AppType::Claude, \"missing\")\n .expect_err(\"switching missing provider should fail\");\n match err {\n AppError::Message(msg) => {\n assert!(\n msg.contains(\"不存在\") || msg.contains(\"not found\"),\n \"expected provider not found message, got {msg}\"\n );\n }\n other => panic!(\"expected Message error for provider not found, got {other:?}\"),\n }\n}\n\n#[test]\nfn provider_service_switch_codex_missing_auth_returns_error() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.providers.insert(\n \"invalid\".to_string(),\n Provider::with_id(\n \"invalid\".to_string(),\n \"Broken Codex\".to_string(),\n json!({\n \"config\": \"[mcp_servers.test]\\ncommand = \\\"noop\\\"\"\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n let err = ProviderService::switch(&state, AppType::Codex, \"invalid\")\n .expect_err(\"switching should fail without auth\");\n match err {\n AppError::Config(msg) => assert!(\n msg.contains(\"auth\"),\n \"expected auth related message, got {msg}\"\n ),\n other => panic!(\"expected config error, got {other:?}\"),\n }\n}\n\n#[test]\nfn provider_service_delete_codex_removes_provider_and_files() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"keep\".to_string();\n manager.providers.insert(\n \"keep\".to_string(),\n Provider::with_id(\n \"keep\".to_string(),\n \"Keep\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"keep-key\"},\n \"config\": \"\"\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"to-delete\".to_string(),\n Provider::with_id(\n \"to-delete\".to_string(),\n \"DeleteCodex\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"delete-key\"},\n \"config\": \"\"\n }),\n None,\n ),\n );\n }\n\n let sanitized = sanitize_provider_name(\"DeleteCodex\");\n let codex_dir = home.join(\".codex\");\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n let auth_path = codex_dir.join(format!(\"auth-{sanitized}.json\"));\n let cfg_path = codex_dir.join(format!(\"config-{sanitized}.toml\"));\n std::fs::write(&auth_path, \"{}\").expect(\"seed auth file\");\n std::fs::write(&cfg_path, \"base_url = \\\"https://example\\\"\").expect(\"seed config file\");\n\n let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::delete(&app_state, AppType::Codex, \"to-delete\")\n .expect(\"delete provider should succeed\");\n\n let providers = app_state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"get all providers\");\n assert!(\n !providers.contains_key(\"to-delete\"),\n \"provider entry should be removed\"\n );\n // v3.7.0+ 不再使用供应商特定文件(如 auth-*.json, config-*.toml)\n // 删除供应商只影响数据库记录,不清理这些旧格式文件\n}\n\n#[test]\nfn provider_service_delete_claude_removes_provider_files() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"keep\".to_string();\n manager.providers.insert(\n \"keep\".to_string(),\n Provider::with_id(\n \"keep\".to_string(),\n \"Keep\".to_string(),\n json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"keep-key\" }\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"delete\".to_string(),\n Provider::with_id(\n \"delete\".to_string(),\n \"DeleteClaude\".to_string(),\n json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"delete-key\" }\n }),\n None,\n ),\n );\n }\n\n let sanitized = sanitize_provider_name(\"DeleteClaude\");\n let claude_dir = home.join(\".claude\");\n std::fs::create_dir_all(&claude_dir).expect(\"create claude dir\");\n let by_name = claude_dir.join(format!(\"settings-{sanitized}.json\"));\n let by_id = claude_dir.join(\"settings-delete.json\");\n std::fs::write(&by_name, \"{}\").expect(\"seed settings by name\");\n std::fs::write(&by_id, \"{}\").expect(\"seed settings by id\");\n\n let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::delete(&app_state, AppType::Claude, \"delete\").expect(\"delete claude provider\");\n\n let providers = app_state\n .db\n .get_all_providers(AppType::Claude.as_str())\n .expect(\"get all providers\");\n assert!(\n !providers.contains_key(\"delete\"),\n \"claude provider should be removed\"\n );\n // v3.7.0+ 不再使用供应商特定文件(如 settings-*.json)\n // 删除供应商只影响数据库记录,不清理这些旧格式文件\n}\n\n#[test]\nfn provider_service_delete_current_provider_returns_error() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"keep\".to_string();\n manager.providers.insert(\n \"keep\".to_string(),\n Provider::with_id(\n \"keep\".to_string(),\n \"Keep\".to_string(),\n json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"keep-key\" }\n }),\n None,\n ),\n );\n }\n\n let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n let err = ProviderService::delete(&app_state, AppType::Claude, \"keep\")\n .expect_err(\"deleting current provider should fail\");\n match err {\n AppError::Localized { zh, .. } => assert!(\n zh.contains(\"不能删除当前正在使用的供应商\")\n || zh.contains(\"无法删除当前正在使用的供应商\"),\n \"unexpected message: {zh}\"\n ),\n AppError::Config(msg) => assert!(\n msg.contains(\"不能删除当前正在使用的供应商\")\n || msg.contains(\"无法删除当前正在使用的供应商\"),\n \"unexpected message: {msg}\"\n ),\n AppError::Message(msg) => assert!(\n msg.contains(\"不能删除当前正在使用的供应商\")\n || msg.contains(\"无法删除当前正在使用的供应商\"),\n \"unexpected message: {msg}\"\n ),\n other => panic!(\"expected Config/Message error, got {other:?}\"),\n }\n}\n\n#[test]\nfn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_back() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n // 接管态 Claude Live,且 DB 中无备份(模拟切换 app_config_dir 后新库首启的场景)\n let taken_over_live = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_AUTH_TOKEN\": \"PROXY_MANAGED\"\n }\n });\n let settings_path = get_claude_settings_path();\n std::fs::create_dir_all(settings_path.parent().expect(\"settings dir\")).expect(\"create dir\");\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&taken_over_live).expect(\"serialize taken over live\"),\n )\n .expect(\"write taken over live\");\n\n let state = create_test_state().expect(\"create test state\");\n\n // 模拟历史异常:接管态 Live 已被导入成 current provider(SSOT 被污染)\n let provider = Provider::with_id(\n \"default\".to_string(),\n \"default\".to_string(),\n taken_over_live.clone(),\n None,\n );\n state\n .db\n .save_provider(AppType::Claude.as_str(), &provider)\n .expect(\"save placeholder provider\");\n state\n .db\n .set_current_provider(AppType::Claude.as_str(), \"default\")\n .expect(\"set current provider\");\n\n futures::executor::block_on(state.proxy_service.recover_from_crash())\n .expect(\"recover from crash\");\n\n let live_after: serde_json::Value =\n read_json_file(&settings_path).expect(\"read live settings after recovery\");\n let env = live_after.get(\"env\").cloned().unwrap_or_else(|| json!({}));\n assert_ne!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"PROXY_MANAGED\"),\n \"recovery must not write the placeholder back to live\"\n );\n assert!(\n env.get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(|url| !url.starts_with(\"http://127.0.0.1\"))\n .unwrap_or(true),\n \"recovery must drop the local proxy base URL\"\n );\n}\n"}, "ef23931ec0d06fa7680728f79acdc6527eba3632": {"t": 1785761099.1628492, "n": 77803, "h": "ec288d33bcc6de83", "c": "use serde_json::json;\n\nuse cc_switch_lib::{\n get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType, McpApps,\n McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,\n};\n\n#[path = \"support.rs\"]\nmod support;\nuse support::{\n create_test_state, create_test_state_with_config, enable_codex_official_auth_preservation,\n ensure_test_home, reset_test_fs, test_mutex,\n};\n\nfn sanitize_provider_name(name: &str) -> String {\n name.chars()\n .map(|c| match c {\n '<' | '>' | ':' | '\"' | '/' | '\\\\' | '|' | '?' | '*' => '-',\n _ => c,\n })\n .collect::<String>()\n .to_lowercase()\n}\n\n#[test]\nfn migrate_legacy_common_config_usage_marks_historical_provider_enabled() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"legacy-provider\".to_string();\n manager.providers.insert(\n \"legacy-provider\".to_string(),\n Provider::with_id(\n \"legacy-provider\".to_string(),\n \"Legacy\".to_string(),\n json!({\n \"includeCoAuthoredBy\": false,\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"legacy-key\"\n }\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Claude.as_str(),\n Some(r#\"{ \"includeCoAuthoredBy\": false }\"#.to_string()),\n )\n .expect(\"set common config snippet\");\n\n ProviderService::migrate_legacy_common_config_usage_if_needed(&state, AppType::Claude)\n .expect(\"migrate legacy common config\");\n\n let providers = state\n .db\n .get_all_providers(AppType::Claude.as_str())\n .expect(\"get providers after migration\");\n let provider = providers\n .get(\"legacy-provider\")\n .expect(\"legacy provider exists\");\n\n assert_eq!(\n provider\n .meta\n .as_ref()\n .and_then(|meta| meta.common_config_enabled),\n Some(true),\n \"historical provider should be explicitly marked as using common config\"\n );\n assert!(\n provider\n .settings_config\n .get(\"includeCoAuthoredBy\")\n .is_none(),\n \"common config fields should be stripped from provider storage after migration\"\n );\n assert_eq!(\n provider\n .settings_config\n .get(\"env\")\n .and_then(|v| v.get(\"ANTHROPIC_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-key\"),\n \"provider-specific auth should remain untouched\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_updates_live_and_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n enable_codex_official_auth_preservation();\n let _home = ensure_test_home();\n\n let legacy_auth = json!({ \"OPENAI_API_KEY\": \"legacy-key\" });\n let legacy_config = r#\"[mcp_servers.legacy]\ntype = \"stdio\"\ncommand = \"echo\"\n\"#;\n write_codex_live_atomic(&legacy_auth, Some(legacy_config))\n .expect(\"seed existing codex live config\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"old-provider\".to_string();\n manager.providers.insert(\n \"old-provider\".to_string(),\n Provider::with_id(\n \"old-provider\".to_string(),\n \"Legacy\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"stale\"},\n \"config\": \"stale-config\"\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"new-provider\".to_string(),\n Provider::with_id(\n \"new-provider\".to_string(),\n \"Latest\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"fresh-key\"},\n \"config\": r#\"[mcp_servers.latest]\ntype = \"stdio\"\ncommand = \"say\"\n\"#\n }),\n None,\n ),\n );\n }\n\n // 使用新的统一 MCP 结构(v3.7.0+)\n let servers = initial_config\n .mcp\n .servers\n .get_or_insert_with(Default::default);\n servers.insert(\n \"echo-server\".into(),\n McpServer {\n id: \"echo-server\".into(),\n name: \"Echo Server\".into(),\n server: json!({\n \"type\": \"stdio\",\n \"command\": \"echo\"\n }),\n apps: McpApps {\n claude: false,\n codex: true,\n gemini: false,\n opencode: false,\n hermes: false,\n },\n description: None,\n homepage: None,\n docs: None,\n tags: Vec::new(),\n },\n );\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"new-provider\")\n .expect(\"switch provider should succeed\");\n\n let auth_value: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n assert_eq!(\n auth_value.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()),\n Some(\"legacy-key\"),\n \"Codex provider switching should preserve the existing live auth.json\"\n );\n\n let config_text =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n assert!(\n config_text.contains(\"mcp_servers.echo-server\"),\n \"config.toml should contain synced MCP servers\"\n );\n assert!(\n config_text.contains(\"experimental_bearer_token\"),\n \"config.toml should carry the selected provider API key\"\n );\n\n let current_id = state\n .db\n .get_current_provider(AppType::Codex.as_str())\n .expect(\"read current provider after switch\");\n assert_eq!(\n current_id.as_deref(),\n Some(\"new-provider\"),\n \"current provider updated\"\n );\n\n let providers = state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"read providers after switch\");\n\n let new_provider = providers.get(\"new-provider\").expect(\"new provider exists\");\n let new_config_text = new_provider\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or_default();\n // provider 存储的是原始配置,不包含 MCP 同步后的内容\n assert!(\n new_config_text.contains(\"mcp_servers.latest\"),\n \"provider config should contain original MCP servers\"\n );\n // live 文件额外包含同步的 MCP 服务器\n assert!(\n config_text.contains(\"mcp_servers.echo-server\"),\n \"live config should include synced MCP servers\"\n );\n\n let legacy = providers\n .get(\"old-provider\")\n .expect(\"legacy provider still exists\");\n let legacy_auth_value = legacy\n .settings_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n assert_eq!(\n legacy_auth_value, \"legacy-key\",\n \"previous provider should be backfilled with live auth\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_preserves_user_model_provider_id_after_migration() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let legacy_auth = json!({ \"OPENAI_API_KEY\": \"rightcode-key\" });\n let legacy_config = r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n write_codex_live_atomic(&legacy_auth, Some(legacy_config))\n .expect(\"seed existing codex live config\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"old-provider\".to_string();\n manager.providers.insert(\n \"old-provider\".to_string(),\n Provider::with_id(\n \"old-provider\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"stale\"},\n \"config\": legacy_config\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"new-provider\".to_string(),\n Provider::with_id(\n \"new-provider\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"fresh-key\"},\n \"config\": r#\"model_provider = \"aihubmix\"\nmodel = \"gpt-5.4\"\n\n[model_providers.aihubmix]\nname = \"AiHubMix\"\nbase_url = \"https://aihubmix.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"new-provider\")\n .expect(\"switch provider should succeed\");\n\n let config_text =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n let parsed: toml::Value = toml::from_str(&config_text).expect(\"parse config.toml\");\n\n assert_eq!(\n parsed.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider switching should preserve user-editable model_provider after the one-time migration\"\n );\n\n let model_providers = parsed\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"model_providers table exists\");\n assert!(\n model_providers.get(\"custom\").is_none(),\n \"provider switching should not force user-edited provider ids back to custom\"\n );\n assert_eq!(\n model_providers\n .get(\"aihubmix\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"selected provider id should point at the newly selected supplier endpoint\"\n );\n\n let providers = state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"read providers after switch\");\n let new_config_text = providers\n .get(\"new-provider\")\n .expect(\"new provider exists\")\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or_default();\n assert!(\n new_config_text.contains(\"[model_providers.aihubmix]\"),\n \"stored provider template should remain provider-specific\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_preserves_oauth_and_backfills_api_key_from_live_token() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n enable_codex_official_auth_preservation();\n let _home = ensure_test_home();\n\n let live_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"oauth-token\",\n \"account_id\": \"acct-1\"\n }\n });\n let legacy_config = r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n write_codex_live_atomic(&live_auth, Some(legacy_config))\n .expect(\"seed existing Codex OAuth live config\");\n\n let bridge_provider = Provider::with_id(\n \"bridge-provider\".to_string(),\n \"Bridge Provider\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"bridge-key\"},\n \"config\": r#\"model_provider = \"aihubmix\"\nmodel = \"gpt-5.4\"\n\n[model_providers.aihubmix]\nname = \"AiHubMix\"\nbase_url = \"https://aihubmix.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"legacy-provider\".to_string();\n manager.providers.insert(\n \"legacy-provider\".to_string(),\n Provider::with_id(\n \"legacy-provider\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"rightcode-key\"},\n \"config\": legacy_config\n }),\n None,\n ),\n );\n manager\n .providers\n .insert(\"bridge-provider\".to_string(), bridge_provider);\n manager.providers.insert(\n \"plain-provider\".to_string(),\n Provider::with_id(\n \"plain-provider\".to_string(),\n \"Plain Provider\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"plain-key\"},\n \"config\": r#\"model_provider = \"plain\"\nmodel = \"gpt-5.4\"\n\n[model_providers.plain]\nname = \"Plain\"\nbase_url = \"https://plain.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"bridge-provider\")\n .expect(\"switch to bridge provider should succeed\");\n\n let auth_value: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n assert_eq!(\n auth_value.get(\"auth_mode\").and_then(|v| v.as_str()),\n Some(\"chatgpt\")\n );\n assert!(\n auth_value\n .get(\"OPENAI_API_KEY\")\n .is_some_and(|v| v.is_null()),\n \"provider switching should keep OPENAI_API_KEY null in live auth.json\"\n );\n assert_eq!(\n auth_value\n .pointer(\"/tokens/access_token\")\n .and_then(|v| v.as_str()),\n Some(\"oauth-token\"),\n \"existing ChatGPT OAuth token should be preserved\"\n );\n\n let live_config =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n let parsed_live: toml::Value = toml::from_str(&live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .and_then(|v| v.get(\"experimental_bearer_token\"))\n .and_then(|v| v.as_str()),\n Some(\"bridge-key\"),\n \"third-party key should be injected into the selected live provider table\"\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .and_then(|v| v.get(\"requires_openai_auth\"))\n .and_then(|v| v.as_bool()),\n Some(true)\n );\n\n ProviderService::switch(&state, AppType::Codex, \"plain-provider\")\n .expect(\"switch away should backfill bridge provider\");\n\n let providers = state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"read providers\");\n let stored_bridge = providers\n .get(\"bridge-provider\")\n .expect(\"bridge provider exists after backfill\");\n assert_eq!(\n stored_bridge\n .settings_config\n .pointer(\"/auth/OPENAI_API_KEY\")\n .and_then(|v| v.as_str()),\n Some(\"bridge-key\"),\n \"backfill should restore the API key into stored provider auth\"\n );\n assert!(\n stored_bridge\n .settings_config\n .pointer(\"/auth/tokens\")\n .is_none(),\n \"backfill should not persist ChatGPT OAuth tokens into provider storage\"\n );\n assert!(\n !stored_bridge\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or_default()\n .contains(\"experimental_bearer_token\"),\n \"stored provider config should stay clean; bridge token is generated only for live config\"\n );\n}\n\n#[tokio::test(flavor = \"current_thread\")]\n#[allow(\n clippy::await_holding_lock,\n reason = \"this integration-style test must serialize global test HOME and settings mutations across async takeover calls\"\n)]\nasync fn codex_official_to_deepseek_then_takeover_enters_and_restores_proxy_managed_live_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n enable_codex_official_auth_preservation();\n let _home = ensure_test_home();\n\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"access_token\": \"oauth-access\",\n \"id_token\": \"oauth-id\"\n }\n });\n let official_config = r#\"model_provider = \"openai\"\nmodel = \"gpt-5\"\n\n[model_providers.openai]\nname = \"OpenAI\"\nwire_api = \"responses\"\n\"#;\n write_codex_live_atomic(&oauth_auth, Some(official_config))\n .expect(\"seed official Codex OAuth live config\");\n\n let deepseek_provider_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-chat\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"official-provider\".to_string();\n\n let mut official_provider = Provider::with_id(\n \"official-provider\".to_string(),\n \"OpenAI Official\".to_string(),\n json!({\n \"auth\": oauth_auth,\n \"config\": official_config\n }),\n None,\n );\n official_provider.category = Some(\"official\".to_string());\n manager\n .providers\n .insert(\"official-provider\".to_string(), official_provider);\n\n let mut deepseek_provider = Provider::with_id(\n \"deepseek-provider\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"deepseek-key\"},\n \"config\": deepseek_provider_config\n }),\n None,\n );\n deepseek_provider.category = Some(\"custom\".to_string());\n manager\n .providers\n .insert(\"deepseek-provider\".to_string(), deepseek_provider);\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n let mut proxy_config = state.db.get_proxy_config().await.expect(\"get proxy config\");\n proxy_config.listen_port = 0;\n state\n .db\n .update_proxy_config(proxy_config)\n .await\n .expect(\"use ephemeral proxy port\");\n\n ProviderService::switch(&state, AppType::Codex, \"deepseek-provider\")\n .expect(\"switch from official subscription to DeepSeek\");\n\n let auth_after_switch: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth after switch\");\n assert_eq!(\n auth_after_switch, oauth_auth,\n \"normal provider switch with Codex preservation enabled must keep OAuth auth.json\"\n );\n\n let config_after_switch =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config\");\n assert!(\n config_after_switch.contains(\"https://api.deepseek.com/v1\"),\n \"normal switch should write the DeepSeek endpoint before takeover\"\n );\n assert!(\n config_after_switch.contains(\"deepseek-key\"),\n \"normal switch should inject the DeepSeek key into config.toml\"\n );\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"enable Codex takeover\");\n let proxy_status = state\n .proxy_service\n .get_status()\n .await\n .expect(\"read proxy status after takeover\");\n let codex_proxy_base_url = format!(\"http://127.0.0.1:{}/v1\", proxy_status.port);\n\n let auth_after_takeover: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth after takeover\");\n assert_eq!(\n auth_after_takeover, oauth_auth,\n \"enabling takeover must not rewrite Codex OAuth auth.json\"\n );\n\n let config_after_takeover =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config\");\n assert!(\n config_after_takeover.contains(&codex_proxy_base_url),\n \"enabling takeover should point Codex config.toml at the local proxy\"\n );\n assert!(\n config_after_takeover.contains(\"PROXY_MANAGED\"),\n \"enabling takeover should move the proxy placeholder into config.toml\"\n );\n assert!(\n !config_after_takeover.contains(\"https://api.deepseek.com/v1\"),\n \"takeover live config should not keep the upstream DeepSeek endpoint\"\n );\n\n let backup = state\n .db\n .get_live_backup(\"codex\")\n .await\n .expect(\"read Codex backup\")\n .expect(\"backup exists after takeover\");\n let backup_value: serde_json::Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n let backup_config = backup_value\n .get(\"config\")\n .and_then(|value| value.as_str())\n .unwrap_or_default();\n assert!(\n backup_config.contains(\"https://api.deepseek.com/v1\")\n && backup_config.contains(\"deepseek-key\"),\n \"takeover backup should remain the restorable DeepSeek config\"\n );\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n\n let restored_auth: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read restored auth\");\n assert_eq!(\n restored_auth, oauth_auth,\n \"disabling takeover should restore without replacing OAuth auth.json\"\n );\n\n let restored_config = std::fs::read_to_string(cc_switch_lib::get_codex_config_path())\n .expect(\"read restored config\");\n assert!(\n restored_config.contains(\"https://api.deepseek.com/v1\")\n && restored_config.contains(\"deepseek-key\"),\n \"disabling takeover should restore the selected DeepSeek live config\"\n );\n assert!(\n !restored_config.contains(\"PROXY_MANAGED\"),\n \"restored live config must not keep the proxy placeholder\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_default_overwrites_official_auth_when_preservation_off() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n // Intentionally do NOT enable preservation: this locks the default opt-out\n // behavior where switching to a third-party provider rewrites auth.json,\n // discarding the user's ChatGPT OAuth login. It is the dual of\n // `provider_service_switch_codex_preserves_oauth_and_backfills_api_key_from_live_token`.\n let _home = ensure_test_home();\n\n let live_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"official-oauth-token\",\n \"account_id\": \"acct-1\"\n }\n });\n let legacy_config = r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n write_codex_live_atomic(&live_auth, Some(legacy_config))\n .expect(\"seed existing Codex OAuth live config\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"legacy-provider\".to_string();\n manager.providers.insert(\n \"legacy-provider\".to_string(),\n Provider::with_id(\n \"legacy-provider\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"rightcode-key\"},\n \"config\": legacy_config\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"third-party\".to_string(),\n Provider::with_id(\n \"third-party\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"third-party-key\"},\n \"config\": r#\"model_provider = \"aihubmix\"\nmodel = \"gpt-5.4\"\n\n[model_providers.aihubmix]\nname = \"AiHubMix\"\nbase_url = \"https://aihubmix.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"third-party\")\n .expect(\"switch to third-party provider should succeed\");\n\n let auth_value: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n assert_eq!(\n auth_value.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()),\n Some(\"third-party-key\"),\n \"default (preservation off) should overwrite auth.json with the third-party API key\"\n );\n assert!(\n auth_value.pointer(\"/tokens/access_token\").is_none(),\n \"default switch must clear the official ChatGPT OAuth token from live auth.json\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_supports_official_login_provider_without_auth_write() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let live_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"official-oauth-token\",\n \"account_id\": \"acct-official\"\n }\n });\n write_codex_live_atomic(&live_auth, Some(\"\")).expect(\"seed official OAuth live config\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"legacy-provider\".to_string();\n manager.providers.insert(\n \"legacy-provider\".to_string(),\n Provider::with_id(\n \"legacy-provider\".to_string(),\n \"Legacy\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"legacy-key\"},\n \"config\": r#\"model_provider = \"legacy\"\n\n[model_providers.legacy]\nname = \"Legacy\"\nbase_url = \"https://legacy.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n ),\n );\n let mut official_provider = Provider::with_id(\n \"official-provider\".to_string(),\n \"OpenAI Official\".to_string(),\n json!({\n \"auth\": {},\n \"config\": \"\"\n }),\n None,\n );\n official_provider.category = Some(\"official\".to_string());\n manager\n .providers\n .insert(\"official-provider\".to_string(), official_provider);\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"official-provider\")\n .expect(\"switch to official provider should succeed without API key\");\n\n let auth_value: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n assert_eq!(\n auth_value.get(\"auth_mode\").and_then(|v| v.as_str()),\n Some(\"chatgpt\")\n );\n assert!(\n auth_value\n .get(\"OPENAI_API_KEY\")\n .is_some_and(|v| v.is_null()),\n \"official provider switching should keep OPENAI_API_KEY null\"\n );\n assert_eq!(\n auth_value\n .pointer(\"/tokens/access_token\")\n .and_then(|v| v.as_str()),\n Some(\"official-oauth-token\"),\n \"official provider should preserve the existing ChatGPT OAuth token\"\n );\n\n let live_config =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n assert!(\n !live_config.contains(\"experimental_bearer_token\"),\n \"official login provider has no API key to inject\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_official_accounts_write_auth_json() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let live_auth_a = json!({\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"official-a-live-token\",\n \"account_id\": \"acct-a\"\n }\n });\n write_codex_live_atomic(&live_auth_a, Some(\"\")).expect(\"seed official account A live auth\");\n\n let mut official_a = Provider::with_id(\n \"official-a\".to_string(),\n \"Official A\".to_string(),\n json!({\n \"auth\": {\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"stale-a-token\",\n \"account_id\": \"acct-a\"\n }\n },\n \"config\": \"\"\n }),\n None,\n );\n official_a.category = Some(\"official\".to_string());\n\n let mut official_b = Provider::with_id(\n \"official-b\".to_string(),\n \"Official B\".to_string(),\n json!({\n \"auth\": {\n \"auth_mode\": \"chatgpt\",\n \"OPENAI_API_KEY\": null,\n \"tokens\": {\n \"access_token\": \"official-b-token\",\n \"account_id\": \"acct-b\"\n }\n },\n \"config\": \"\"\n }),\n None,\n );\n official_b.category = Some(\"official\".to_string());\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"official-a\".to_string();\n manager\n .providers\n .insert(\"official-a\".to_string(), official_a);\n manager\n .providers\n .insert(\"official-b\".to_string(), official_b);\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"official-b\")\n .expect(\"switch to official account B should write auth.json\");\n let auth_b: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth B\");\n assert_eq!(\n auth_b\n .pointer(\"/tokens/access_token\")\n .and_then(|v| v.as_str()),\n Some(\"official-b-token\"),\n \"switching official accounts must replace auth.json with the selected account\"\n );\n\n ProviderService::switch(&state, AppType::Codex, \"official-a\")\n .expect(\"switch back to official account A should use backfilled live auth\");\n let auth_a: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth A\");\n assert_eq!(\n auth_a\n .pointer(\"/tokens/access_token\")\n .and_then(|v| v.as_str()),\n Some(\"official-a-live-token\"),\n \"backfill should preserve account A's latest live token for later official switches\"\n );\n}\n\n#[test]\nfn provider_service_switch_codex_backfill_keeps_provider_specific_model_provider_id() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let legacy_auth = json!({ \"OPENAI_API_KEY\": \"rightcode-key\" });\n let provider_a_config = r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n write_codex_live_atomic(&legacy_auth, Some(provider_a_config))\n .expect(\"seed existing codex live config\");\n\n let mut initial_config = MultiAppConfig::default();\n {\n let manager = initial_config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"provider-a\".to_string();\n manager.providers.insert(\n \"provider-a\".to_string(),\n Provider::with_id(\n \"provider-a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"rightcode-key\"},\n \"config\": provider_a_config\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"provider-b\".to_string(),\n Provider::with_id(\n \"provider-b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"aihubmix-key\"},\n \"config\": r#\"model_provider = \"aihubmix\"\nmodel = \"gpt-5.4\"\nprofile = \"work\"\n\n[model_providers.aihubmix]\nname = \"AiHubMix\"\nbase_url = \"https://aihubmix.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\n[profiles.work]\nmodel_provider = \"aihubmix\"\nmodel = \"gpt-5.4\"\n\"#\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"provider-c\".to_string(),\n Provider::with_id(\n \"provider-c\".to_string(),\n \"Vendor C\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"vendor-c-key\"},\n \"config\": r#\"model_provider = \"vendor_c\"\nmodel = \"gpt-5.4\"\n\n[model_providers.vendor_c]\nname = \"Vendor C\"\nbase_url = \"https://vendor-c.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&initial_config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Codex, \"provider-b\")\n .expect(\"switch to provider b should succeed\");\n ProviderService::switch(&state, AppType::Codex, \"provider-c\")\n .expect(\"switch to provider c should succeed\");\n\n let providers = state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"read providers after switches\");\n let provider_b_config = providers\n .get(\"provider-b\")\n .expect(\"provider b exists\")\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"provider b config\");\n let parsed: toml::Value = toml::from_str(provider_b_config).expect(\"parse provider b config\");\n\n assert_eq!(\n parsed.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"backfill should restore provider b's storage-specific model_provider id\"\n );\n assert!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .is_some(),\n \"provider b should keep its own model_providers table after backfill\"\n );\n assert_eq!(\n parsed\n .get(\"profiles\")\n .and_then(|v| v.get(\"work\"))\n .and_then(|v| v.get(\"model_provider\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"profile overrides should be restored to provider b's storage-specific id\"\n );\n}\n\n#[test]\nfn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"current-provider\".to_string();\n\n let mut provider = Provider::with_id(\n \"current-provider\".to_string(),\n \"Current\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"real-token\",\n \"ANTHROPIC_BASE_URL\": \"https://claude.example\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n manager\n .providers\n .insert(\"current-provider\".to_string(), provider);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Claude.as_str(),\n Some(r#\"{ \"includeCoAuthoredBy\": false }\"#.to_string()),\n )\n .expect(\"set common config snippet\");\n\n let taken_over_live = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:5000\",\n \"ANTHROPIC_AUTH_TOKEN\": \"PROXY_MANAGED\"\n }\n });\n let settings_path = get_claude_settings_path();\n std::fs::create_dir_all(settings_path.parent().expect(\"settings dir\")).expect(\"create dir\");\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&taken_over_live).expect(\"serialize taken over live\"),\n )\n .expect(\"write taken over live\");\n\n futures::executor::block_on(state.db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\"))\n .expect(\"seed live backup\");\n\n let mut proxy_config = futures::executor::block_on(state.db.get_proxy_config_for_app(\"claude\"))\n .expect(\"get proxy config\");\n proxy_config.enabled = true;\n futures::executor::block_on(state.db.update_proxy_config_for_app(proxy_config))\n .expect(\"enable takeover\");\n\n ProviderService::sync_current_provider_for_app(&state, AppType::Claude)\n .expect(\"sync current provider should succeed\");\n\n let live_after: serde_json::Value =\n read_json_file(&settings_path).expect(\"read live settings after sync\");\n assert_eq!(\n live_after, taken_over_live,\n \"sync should not overwrite live config while takeover is active\"\n );\n\n let backup = futures::executor::block_on(state.db.get_live_backup(\"claude\"))\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let backup_value: serde_json::Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup value\");\n\n assert_eq!(\n backup_value\n .get(\"includeCoAuthoredBy\")\n .and_then(|v| v.as_bool()),\n Some(false),\n \"restore backup should receive the updated effective config\"\n );\n assert_eq!(\n backup_value\n .get(\"env\")\n .and_then(|v| v.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|v| v.as_str()),\n Some(\"real-token\"),\n \"restore backup should preserve the provider token rather than proxy placeholder\"\n );\n}\n\n#[test]\nfn switch_codex_provider_with_takeover_live_but_stopped_proxy_keeps_proxy_live_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n enable_codex_official_auth_preservation();\n let _home = ensure_test_home();\n\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"access_token\": \"oauth-access\",\n \"id_token\": \"oauth-id\"\n }\n });\n let old_provider_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-chat\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"old-key\"\n\"#;\n let proxy_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-chat\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"PROXY_MANAGED\"\n\"#;\n write_codex_live_atomic(&oauth_auth, Some(proxy_live_config))\n .expect(\"seed taken-over Codex live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"old-provider\".to_string();\n\n let mut old_provider = Provider::with_id(\n \"old-provider\".to_string(),\n \"DeepSeek Old\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"old-key\"},\n \"config\": old_provider_config\n }),\n None,\n );\n old_provider.category = Some(\"custom\".to_string());\n manager\n .providers\n .insert(\"old-provider\".to_string(), old_provider);\n\n let mut new_provider = Provider::with_id(\n \"new-provider\".to_string(),\n \"DeepSeek New\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"new-key\"},\n \"config\": r#\"model_provider = \"deepseek-new\"\nmodel = \"deepseek-reasoner\"\n\n[model_providers.deepseek-new]\nname = \"DeepSeek New\"\nbase_url = \"https://new.deepseek.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n new_provider.category = Some(\"custom\".to_string());\n manager\n .providers\n .insert(\"new-provider\".to_string(), new_provider);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n futures::executor::block_on(\n state.db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": oauth_auth,\n \"config\": old_provider_config\n }))\n .expect(\"serialize backup\"),\n ),\n )\n .expect(\"seed Codex live backup\");\n\n assert!(\n !futures::executor::block_on(state.proxy_service.is_running()),\n \"fixture keeps the proxy server stopped\"\n );\n\n ProviderService::switch(&state, AppType::Codex, \"new-provider\")\n .expect(\"switch should update takeover backup instead of writing normal live config\");\n\n let auth_after: serde_json::Value =\n read_json_file(&cc_switch_lib::get_codex_auth_path()).expect(\"read auth.json\");\n assert_eq!(\n auth_after, oauth_auth,\n \"provider switch during takeover ownership must not rewrite Codex OAuth auth\"\n );\n\n let live_config =\n std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect(\"read config.toml\");\n assert!(\n live_config.contains(\"http://127.0.0.1:15721/v1\"),\n \"live config should remain pointed at the local proxy\"\n );\n assert!(\n live_config.contains(\"PROXY_MANAGED\"),\n \"live config should keep the proxy bearer placeholder\"\n );\n assert!(\n live_config.contains(r#\"model_provider = \"deepseek-new\"\"#)\n && live_config.contains(r#\"name = \"DeepSeek New\"\"#),\n \"live config should update the Codex-visible provider label during takeover\"\n );\n assert!(\n !live_config.contains(\"https://new.deepseek.example/v1\"),\n \"normal provider base_url must not overwrite taken-over live config\"\n );\n\n let backup = futures::executor::block_on(state.db.get_live_backup(\"codex\"))\n .expect(\"get Codex backup\")\n .expect(\"backup exists\");\n let backup_value: serde_json::Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n assert_eq!(\n backup_value.get(\"auth\"),\n Some(&auth_after),\n \"restore backup should preserve the official OAuth auth\"\n );\n let backup_config = backup_value\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or_default();\n assert!(\n backup_config.contains(\"new-key\") && backup_config.contains(\"deepseek-new\"),\n \"restore backup should be rebuilt from the newly selected provider\"\n );\n\n let current = state\n .db\n .get_current_provider(AppType::Codex.as_str())\n .expect(\"get current provider\");\n assert_eq!(current.as_deref(), Some(\"new-provider\"));\n}\n\n#[test]\nfn explicitly_cleared_common_snippet_is_not_auto_extracted() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let state = create_test_state().expect(\"create test state\");\n state\n .db\n .set_config_snippet_cleared(AppType::Claude.as_str(), true)\n .expect(\"mark snippet explicitly cleared\");\n\n assert!(\n !state\n .db\n .should_auto_extract_config_snippet(AppType::Claude.as_str())\n .expect(\"check auto-extract eligibility\"),\n \"explicitly cleared snippets should block auto-extraction\"\n );\n\n state\n .db\n .set_config_snippet(AppType::Claude.as_str(), Some(\"{}\".to_string()))\n .expect(\"set snippet\");\n state\n .db\n .set_config_snippet_cleared(AppType::Claude.as_str(), false)\n .expect(\"clear explicit-empty marker\");\n\n assert!(\n !state\n .db\n .should_auto_extract_config_snippet(AppType::Claude.as_str())\n .expect(\"check auto-extract after snippet saved\"),\n \"existing snippets should also block auto-extraction\"\n );\n}\n\n#[test]\nfn legacy_common_config_migration_flag_roundtrip() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let state = create_test_state().expect(\"create test state\");\n\n assert!(\n !state\n .db\n .is_legacy_common_config_migrated()\n .expect(\"initial migration flag\"),\n \"migration flag should default to false\"\n );\n\n state\n .db\n .set_legacy_common_config_migrated(true)\n .expect(\"set migration flag\");\n assert!(\n state\n .db\n .is_legacy_common_config_migrated()\n .expect(\"read migration flag\"),\n \"migration flag should persist once set\"\n );\n\n state\n .db\n .set_legacy_common_config_migrated(false)\n .expect(\"clear migration flag\");\n assert!(\n !state\n .db\n .is_legacy_common_config_migrated()\n .expect(\"read migration flag after clear\"),\n \"migration flag should be removable for tests/debugging\"\n );\n}\n\n#[test]\nfn switch_packycode_gemini_updates_security_selected_type() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Gemini)\n .expect(\"gemini manager\");\n manager.current = \"packy-gemini\".to_string();\n manager.providers.insert(\n \"packy-gemini\".to_string(),\n Provider::with_id(\n \"packy-gemini\".to_string(),\n \"PackyCode\".to_string(),\n json!({\n \"env\": {\n \"GEMINI_API_KEY\": \"pk-key\",\n \"GOOGLE_GEMINI_BASE_URL\": \"https://www.packyapi.com\"\n }\n }),\n Some(\"https://www.packyapi.com\".to_string()),\n ),\n );\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Gemini, \"packy-gemini\")\n .expect(\"switching to PackyCode Gemini should succeed\");\n\n // Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json\n let settings_path = home.join(\".gemini\").join(\"settings.json\");\n assert!(\n settings_path.exists(),\n \"Gemini settings.json should exist at {}\",\n settings_path.display()\n );\n let raw = std::fs::read_to_string(&settings_path).expect(\"read gemini settings.json\");\n let value: serde_json::Value =\n serde_json::from_str(&raw).expect(\"parse gemini settings.json after switch\");\n\n assert_eq!(\n value\n .pointer(\"/security/auth/selectedType\")\n .and_then(|v| v.as_str()),\n Some(\"gemini-api-key\"),\n \"PackyCode Gemini should set security.auth.selectedType\"\n );\n}\n\n#[test]\nfn packycode_partner_meta_triggers_security_flag_even_without_keywords() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Gemini)\n .expect(\"gemini manager\");\n manager.current = \"packy-meta\".to_string();\n let mut provider = Provider::with_id(\n \"packy-meta\".to_string(),\n \"Generic Gemini\".to_string(),\n json!({\n \"env\": {\n \"GEMINI_API_KEY\": \"pk-meta\",\n \"GOOGLE_GEMINI_BASE_URL\": \"https://generativelanguage.googleapis.com\"\n }\n }),\n Some(\"https://example.com\".to_string()),\n );\n provider.meta = Some(ProviderMeta {\n partner_promotion_key: Some(\"packycode\".to_string()),\n ..ProviderMeta::default()\n });\n manager.providers.insert(\"packy-meta\".to_string(), provider);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Gemini, \"packy-meta\")\n .expect(\"switching to partner meta provider should succeed\");\n\n // Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json\n let settings_path = home.join(\".gemini\").join(\"settings.json\");\n assert!(\n settings_path.exists(),\n \"Gemini settings.json should exist at {}\",\n settings_path.display()\n );\n let raw = std::fs::read_to_string(&settings_path).expect(\"read gemini settings.json\");\n let value: serde_json::Value =\n serde_json::from_str(&raw).expect(\"parse gemini settings.json after switch\");\n\n assert_eq!(\n value\n .pointer(\"/security/auth/selectedType\")\n .and_then(|v| v.as_str()),\n Some(\"gemini-api-key\"),\n \"Partner meta should set security.auth.selectedType even without packy keywords\"\n );\n}\n\n#[test]\nfn switch_google_official_gemini_preserves_env_vars() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Gemini)\n .expect(\"gemini manager\");\n manager.current = \"google-official\".to_string();\n let mut provider = Provider::with_id(\n \"google-official\".to_string(),\n \"Google\".to_string(),\n json!({\n \"env\": {\n \"GEMINI_MODEL\": \"gemini-2.5-pro\"\n }\n }),\n Some(\"https://ai.google.dev\".to_string()),\n );\n provider.meta = Some(ProviderMeta {\n partner_promotion_key: Some(\"google-official\".to_string()),\n ..ProviderMeta::default()\n });\n manager\n .providers\n .insert(\"google-official\".to_string(), provider);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Gemini, \"google-official\")\n .expect(\"switching to Google official Gemini should succeed\");\n\n // Verify env vars are preserved in ~/.gemini/.env\n let env_path = home.join(\".gemini\").join(\".env\");\n assert!(\n env_path.exists(),\n \"Gemini .env should exist at {}\",\n env_path.display()\n );\n let env_content = std::fs::read_to_string(&env_path).expect(\"read gemini .env\");\n assert!(\n env_content.contains(\"GEMINI_MODEL=gemini-2.5-pro\"),\n \"GEMINI_MODEL should be preserved in .env, got: {env_content}\"\n );\n\n // Verify OAuth security flag is still set correctly\n let gemini_settings = home.join(\".gemini\").join(\"settings.json\");\n let gemini_raw = std::fs::read_to_string(&gemini_settings).expect(\"read gemini settings\");\n let gemini_value: serde_json::Value =\n serde_json::from_str(&gemini_raw).expect(\"parse gemini settings\");\n assert_eq!(\n gemini_value\n .pointer(\"/security/auth/selectedType\")\n .and_then(|v| v.as_str()),\n Some(\"oauth-personal\"),\n \"OAuth security flag should still be set\"\n );\n}\n\n#[test]\nfn provider_service_switch_claude_updates_live_and_state() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let settings_path = get_claude_settings_path();\n if let Some(parent) = settings_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n }\n let legacy_live = json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"legacy-key\"\n },\n \"workspace\": {\n \"path\": \"/tmp/workspace\"\n }\n });\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&legacy_live).expect(\"serialize legacy live\"),\n )\n .expect(\"seed claude live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"old-provider\".to_string();\n manager.providers.insert(\n \"old-provider\".to_string(),\n Provider::with_id(\n \"old-provider\".to_string(),\n \"Legacy Claude\".to_string(),\n json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"stale-key\" }\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"new-provider\".to_string(),\n Provider::with_id(\n \"new-provider\".to_string(),\n \"Fresh Claude\".to_string(),\n json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"fresh-key\" },\n \"workspace\": { \"path\": \"/tmp/new-workspace\" }\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::switch(&state, AppType::Claude, \"new-provider\")\n .expect(\"switch provider should succeed\");\n\n let live_after: serde_json::Value =\n read_json_file(&settings_path).expect(\"read claude live settings\");\n assert_eq!(\n live_after\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .and_then(|key| key.as_str()),\n Some(\"fresh-key\"),\n \"live settings.json should reflect new provider auth\"\n );\n\n let providers = state\n .db\n .get_all_providers(AppType::Claude.as_str())\n .expect(\"get all providers\");\n let current_id = state\n .db\n .get_current_provider(AppType::Claude.as_str())\n .expect(\"get current provider\");\n assert_eq!(\n current_id.as_deref(),\n Some(\"new-provider\"),\n \"current provider updated\"\n );\n\n let legacy_provider = providers\n .get(\"old-provider\")\n .expect(\"legacy provider still exists\");\n assert_eq!(\n legacy_provider.settings_config, legacy_live,\n \"previous provider should receive backfilled live config\"\n );\n}\n\n/// 切走勾选了通用配置的 Claude 供应商时,应把它 live 里新增的可共享键\n/// (用户直接在应用内装插件/改偏好)捕获进通用配置片段,并带到下一个供应商。\n#[test]\nfn switch_claude_syncs_new_shared_keys_from_live_into_common_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let settings_path = get_claude_settings_path();\n if let Some(parent) = settings_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n }\n // A 的 live = A 私有密钥(含非 Anthropic 的 OpenRouter 凭据)+ 已共享的 theme\n // + 用户刚在应用内新增的 enableAllProjectMcpServers\n let live = json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\", \"OPENROUTER_API_KEY\": \"sk-or-leak\" },\n \"theme\": \"dark\",\n \"enableAllProjectMcpServers\": true\n });\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&live).expect(\"serialize live\"),\n )\n .expect(\"seed claude live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"a\".to_string();\n let mut provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" } }),\n None,\n );\n provider_a.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"a\".to_string(), provider_a);\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"b-key\" } }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"b\".to_string(), provider_b);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Claude.as_str(),\n Some(r#\"{\"theme\":\"dark\"}\"#.to_string()),\n )\n .expect(\"seed common config snippet\");\n\n ProviderService::switch(&state, AppType::Claude, \"b\").expect(\"switch should succeed\");\n\n // 片段应捕获到新增键,并保留已有共享键,且绝不含密钥\n let snippet = state\n .db\n .get_config_snippet(AppType::Claude.as_str())\n .expect(\"read snippet\")\n .expect(\"snippet present\");\n let snippet_value: serde_json::Value =\n serde_json::from_str(&snippet).expect(\"snippet is valid JSON\");\n assert_eq!(\n snippet_value.get(\"enableAllProjectMcpServers\"),\n Some(&json!(true)),\n \"newly added shared key should be captured into common config\"\n );\n assert_eq!(\n snippet_value.get(\"theme\").and_then(|v| v.as_str()),\n Some(\"dark\"),\n \"previously shared key should be preserved\"\n );\n assert!(\n snippet_value\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"secrets must never leak into the shared snippet\"\n );\n assert!(\n snippet_value\n .get(\"env\")\n .and_then(|env| env.get(\"OPENROUTER_API_KEY\"))\n .is_none(),\n \"non-Anthropic Claude credentials must never leak into the shared snippet\"\n );\n\n // 新增键应通过通用配置带到 B 的 live\n let live_after: serde_json::Value =\n read_json_file(&settings_path).expect(\"read live after switch\");\n assert_eq!(\n live_after.get(\"enableAllProjectMcpServers\"),\n Some(&json!(true)),\n \"shared key should propagate to the next provider's live config\"\n );\n assert!(\n live_after\n .get(\"env\")\n .and_then(|env| env.get(\"OPENROUTER_API_KEY\"))\n .is_none(),\n \"leaked credential must not be injected into the next provider's live\"\n );\n assert_eq!(\n live_after\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"b-key\"),\n \"live should reflect new provider's own auth\"\n );\n}\n\n/// 用户在应用内删掉一个已共享的键后,切换应把删除同步进通用配置,\n/// 且不会在切到下一个供应商时被重新注入(否则会\"删不掉\")。\n#[test]\nfn switch_claude_syncs_deletions_from_live_into_common_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let settings_path = get_claude_settings_path();\n if let Some(parent) = settings_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n }\n // live 里 theme 还在,但用户已删掉 enableAllProjectMcpServers\n let live = json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" },\n \"theme\": \"dark\"\n });\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&live).expect(\"serialize live\"),\n )\n .expect(\"seed claude live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"a\".to_string();\n let mut provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" } }),\n None,\n );\n provider_a.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"a\".to_string(), provider_a);\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"b-key\" } }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"b\".to_string(), provider_b);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n // 片段里仍残留 enableAllProjectMcpServers(上次共享的)\n state\n .db\n .set_config_snippet(\n AppType::Claude.as_str(),\n Some(r#\"{\"theme\":\"dark\",\"enableAllProjectMcpServers\":true}\"#.to_string()),\n )\n .expect(\"seed common config snippet\");\n\n ProviderService::switch(&state, AppType::Claude, \"b\").expect(\"switch should succeed\");\n\n let snippet = state\n .db\n .get_config_snippet(AppType::Claude.as_str())\n .expect(\"read snippet\")\n .expect(\"snippet present\");\n let snippet_value: serde_json::Value =\n serde_json::from_str(&snippet).expect(\"snippet is valid JSON\");\n assert!(\n snippet_value.get(\"enableAllProjectMcpServers\").is_none(),\n \"deleted key should be removed from common config\"\n );\n assert_eq!(\n snippet_value.get(\"theme\").and_then(|v| v.as_str()),\n Some(\"dark\"),\n \"untouched shared key should remain\"\n );\n\n // 切到 B 后 live 不应再出现被删除的键\n let live_after: serde_json::Value =\n read_json_file(&settings_path).expect(\"read live after switch\");\n assert!(\n live_after.get(\"enableAllProjectMcpServers\").is_none(),\n \"deleted shared key must not be re-injected into the next provider\"\n );\n}\n\n/// 未勾选\"写入通用配置\"的供应商,其 live 改动不应自动污染通用配置片段。\n#[test]\nfn switch_claude_does_not_sync_common_config_for_opted_out_provider() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let settings_path = get_claude_settings_path();\n if let Some(parent) = settings_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n }\n let live = json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" },\n \"providerSpecific\": \"x\"\n });\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&live).expect(\"serialize live\"),\n )\n .expect(\"seed claude live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"a\".to_string();\n // A 未勾选通用配置(meta = None)\n manager.providers.insert(\n \"a\".to_string(),\n Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" } }),\n None,\n ),\n );\n manager.providers.insert(\n \"b\".to_string(),\n Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"b-key\" } }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet(\n AppType::Claude.as_str(),\n Some(r#\"{\"theme\":\"dark\"}\"#.to_string()),\n )\n .expect(\"seed common config snippet\");\n\n ProviderService::switch(&state, AppType::Claude, \"b\").expect(\"switch should succeed\");\n\n let snippet = state\n .db\n .get_config_snippet(AppType::Claude.as_str())\n .expect(\"read snippet\")\n .expect(\"snippet present\");\n let snippet_value: serde_json::Value =\n serde_json::from_str(&snippet).expect(\"snippet is valid JSON\");\n assert!(\n snippet_value.get(\"providerSpecific\").is_none(),\n \"opted-out provider's live changes must not pollute the shared snippet\"\n );\n assert_eq!(\n snippet_value.get(\"theme\").and_then(|v| v.as_str()),\n Some(\"dark\"),\n \"snippet should stay unchanged for opted-out providers\"\n );\n}\n\n/// 用户显式清空过通用配置(_cleared)后,切换不应把片段重新塞回来。\n#[test]\nfn switch_claude_respects_explicitly_cleared_common_config() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let settings_path = get_claude_settings_path();\n if let Some(parent) = settings_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create claude settings dir\");\n }\n let live = json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" },\n \"theme\": \"dark\"\n });\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&live).expect(\"serialize live\"),\n )\n .expect(\"seed claude live config\");\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"a\".to_string();\n let mut provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"a-key\" } }),\n None,\n );\n provider_a.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"a\".to_string(), provider_a);\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"b-key\" } }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n manager.providers.insert(\"b\".to_string(), provider_b);\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n state\n .db\n .set_config_snippet_cleared(AppType::Claude.as_str(), true)\n .expect(\"mark snippet cleared\");\n\n ProviderService::switch(&state, AppType::Claude, \"b\").expect(\"switch should succeed\");\n\n assert!(\n state\n .db\n .get_config_snippet(AppType::Claude.as_str())\n .expect(\"read snippet\")\n .is_none(),\n \"explicitly cleared snippet must not be resurrected by switch-away sync\"\n );\n}\n\n#[test]\nfn provider_service_switch_missing_provider_returns_error() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let state = create_test_state().expect(\"create test state\");\n\n let err = ProviderService::switch(&state, AppType::Claude, \"missing\")\n .expect_err(\"switching missing provider should fail\");\n match err {\n AppError::Message(msg) => {\n assert!(\n msg.contains(\"不存在\") || msg.contains(\"not found\"),\n \"expected provider not found message, got {msg}\"\n );\n }\n other => panic!(\"expected Message error for provider not found, got {other:?}\"),\n }\n}\n\n#[test]\nfn provider_service_switch_codex_missing_auth_returns_error() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.providers.insert(\n \"invalid\".to_string(),\n Provider::with_id(\n \"invalid\".to_string(),\n \"Broken Codex\".to_string(),\n json!({\n \"config\": \"[mcp_servers.test]\\ncommand = \\\"noop\\\"\"\n }),\n None,\n ),\n );\n }\n\n let state = create_test_state_with_config(&config).expect(\"create test state\");\n\n let err = ProviderService::switch(&state, AppType::Codex, \"invalid\")\n .expect_err(\"switching should fail without auth\");\n match err {\n AppError::Config(msg) => assert!(\n msg.contains(\"auth\"),\n \"expected auth related message, got {msg}\"\n ),\n other => panic!(\"expected config error, got {other:?}\"),\n }\n}\n\n#[test]\nfn provider_service_delete_codex_removes_provider_and_files() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Codex)\n .expect(\"codex manager\");\n manager.current = \"keep\".to_string();\n manager.providers.insert(\n \"keep\".to_string(),\n Provider::with_id(\n \"keep\".to_string(),\n \"Keep\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"keep-key\"},\n \"config\": \"\"\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"to-delete\".to_string(),\n Provider::with_id(\n \"to-delete\".to_string(),\n \"DeleteCodex\".to_string(),\n json!({\n \"auth\": {\"OPENAI_API_KEY\": \"delete-key\"},\n \"config\": \"\"\n }),\n None,\n ),\n );\n }\n\n let sanitized = sanitize_provider_name(\"DeleteCodex\");\n let codex_dir = home.join(\".codex\");\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n let auth_path = codex_dir.join(format!(\"auth-{sanitized}.json\"));\n let cfg_path = codex_dir.join(format!(\"config-{sanitized}.toml\"));\n std::fs::write(&auth_path, \"{}\").expect(\"seed auth file\");\n std::fs::write(&cfg_path, \"base_url = \\\"https://example\\\"\").expect(\"seed config file\");\n\n let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::delete(&app_state, AppType::Codex, \"to-delete\")\n .expect(\"delete provider should succeed\");\n\n let providers = app_state\n .db\n .get_all_providers(AppType::Codex.as_str())\n .expect(\"get all providers\");\n assert!(\n !providers.contains_key(\"to-delete\"),\n \"provider entry should be removed\"\n );\n // v3.7.0+ 不再使用供应商特定文件(如 auth-*.json, config-*.toml)\n // 删除供应商只影响数据库记录,不清理这些旧格式文件\n}\n\n#[test]\nfn provider_service_delete_claude_removes_provider_files() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"keep\".to_string();\n manager.providers.insert(\n \"keep\".to_string(),\n Provider::with_id(\n \"keep\".to_string(),\n \"Keep\".to_string(),\n json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"keep-key\" }\n }),\n None,\n ),\n );\n manager.providers.insert(\n \"delete\".to_string(),\n Provider::with_id(\n \"delete\".to_string(),\n \"DeleteClaude\".to_string(),\n json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"delete-key\" }\n }),\n None,\n ),\n );\n }\n\n let sanitized = sanitize_provider_name(\"DeleteClaude\");\n let claude_dir = home.join(\".claude\");\n std::fs::create_dir_all(&claude_dir).expect(\"create claude dir\");\n let by_name = claude_dir.join(format!(\"settings-{sanitized}.json\"));\n let by_id = claude_dir.join(\"settings-delete.json\");\n std::fs::write(&by_name, \"{}\").expect(\"seed settings by name\");\n std::fs::write(&by_id, \"{}\").expect(\"seed settings by id\");\n\n let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n ProviderService::delete(&app_state, AppType::Claude, \"delete\").expect(\"delete claude provider\");\n\n let providers = app_state\n .db\n .get_all_providers(AppType::Claude.as_str())\n .expect(\"get all providers\");\n assert!(\n !providers.contains_key(\"delete\"),\n \"claude provider should be removed\"\n );\n // v3.7.0+ 不再使用供应商特定文件(如 settings-*.json)\n // 删除供应商只影响数据库记录,不清理这些旧格式文件\n}\n\n#[test]\nfn provider_service_delete_current_provider_returns_error() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n let mut config = MultiAppConfig::default();\n {\n let manager = config\n .get_manager_mut(&AppType::Claude)\n .expect(\"claude manager\");\n manager.current = \"keep\".to_string();\n manager.providers.insert(\n \"keep\".to_string(),\n Provider::with_id(\n \"keep\".to_string(),\n \"Keep\".to_string(),\n json!({\n \"env\": { \"ANTHROPIC_API_KEY\": \"keep-key\" }\n }),\n None,\n ),\n );\n }\n\n let app_state = create_test_state_with_config(&config).expect(\"create test state\");\n\n let err = ProviderService::delete(&app_state, AppType::Claude, \"keep\")\n .expect_err(\"deleting current provider should fail\");\n match err {\n AppError::Localized { zh, .. } => assert!(\n zh.contains(\"不能删除当前正在使用的供应商\")\n || zh.contains(\"无法删除当前正在使用的供应商\"),\n \"unexpected message: {zh}\"\n ),\n AppError::Config(msg) => assert!(\n msg.contains(\"不能删除当前正在使用的供应商\")\n || msg.contains(\"无法删除当前正在使用的供应商\"),\n \"unexpected message: {msg}\"\n ),\n AppError::Message(msg) => assert!(\n msg.contains(\"不能删除当前正在使用的供应商\")\n || msg.contains(\"无法删除当前正在使用的供应商\"),\n \"unexpected message: {msg}\"\n ),\n other => panic!(\"expected Config/Message error, got {other:?}\"),\n }\n}\n\n#[test]\nfn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_back() {\n let _guard = test_mutex().lock().expect(\"acquire test mutex\");\n reset_test_fs();\n let _home = ensure_test_home();\n\n // 接管态 Claude Live,且 DB 中无备份(模拟切换 app_config_dir 后新库首启的场景)\n let taken_over_live = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_AUTH_TOKEN\": \"PROXY_MANAGED\"\n }\n });\n let settings_path = get_claude_settings_path();\n std::fs::create_dir_all(settings_path.parent().expect(\"settings dir\")).expect(\"create dir\");\n std::fs::write(\n &settings_path,\n serde_json::to_string_pretty(&taken_over_live).expect(\"serialize taken over live\"),\n )\n .expect(\"write taken over live\");\n\n let state = create_test_state().expect(\"create test state\");\n\n // 模拟历史异常:接管态 Live 已被导入成 current provider(SSOT 被污染)\n let provider = Provider::with_id(\n \"default\".to_string(),\n \"default\".to_string(),\n taken_over_live.clone(),\n None,\n );\n state\n .db\n .save_provider(AppType::Claude.as_str(), &provider)\n .expect(\"save placeholder provider\");\n state\n .db\n .set_current_provider(AppType::Claude.as_str(), \"default\")\n .expect(\"set current provider\");\n\n futures::executor::block_on(state.proxy_service.recover_from_crash())\n .expect(\"recover from crash\");\n\n let live_after: serde_json::Value =\n read_json_file(&settings_path).expect(\"read live settings after recovery\");\n let env = live_after.get(\"env\").cloned().unwrap_or_else(|| json!({}));\n assert_ne!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"PROXY_MANAGED\"),\n \"recovery must not write the placeholder back to live\"\n );\n assert!(\n env.get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(|url| !url.starts_with(\"http://127.0.0.1\"))\n .unwrap_or(true),\n \"recovery must drop the local proxy base URL\"\n );\n}\n"}}, "src-tauri/src/services/provider/mod.rs": {"1e398a3709f03db3fe5d989827090386b9dcd3be": {"t": 1785746257.1670802, "n": 155357, "h": "7f42e5f35c5ff8da", "c": "//! Provider service module\n//!\n//! Handles provider CRUD operations, switching, and configuration management.\n\nmod endpoints;\nmod gemini_auth;\nmod live;\nmod usage;\n\nuse indexmap::IndexMap;\nuse regex::Regex;\nuse serde::Deserialize;\nuse serde_json::Value;\n\nuse crate::app_config::AppType;\nuse crate::database::{validate_cost_multiplier, validate_pricing_source};\nuse crate::error::AppError;\nuse crate::provider::{Provider, UsageResult};\nuse crate::services::mcp::McpService;\nuse crate::settings::CustomEndpoint;\nuse crate::store::AppState;\n\n// Re-export sub-module functions for external access\npub use live::{\n import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live,\n import_opencode_providers_from_live, read_live_settings,\n should_import_default_config_on_startup, sync_current_to_live,\n update_toml_common_config_snippet,\n};\n\n// Internal re-exports (pub(crate))\npub(crate) use live::sanitize_claude_settings_for_live;\npub(crate) use live::{\n build_effective_settings_with_common_config, normalize_provider_common_config_for_storage,\n provider_exists_in_live_config, strip_common_config_from_live_settings,\n sync_current_provider_for_app_to_live, write_live_with_common_config,\n};\n\n// Internal re-exports\nuse live::{\n remove_hermes_provider_from_live, remove_openclaw_provider_from_live,\n remove_opencode_provider_from_live, write_gemini_live,\n};\nuse usage::validate_usage_script;\n\n/// The built-in Codex official provider is safe to select during takeover:\n/// Codex keeps ownership of its ChatGPT login and the proxy only forwards the\n/// authenticated request. Other official providers retain the existing block.\npub fn official_provider_supports_proxy_takeover(app_type: &AppType, provider: &Provider) -> bool {\n matches!(app_type, AppType::Codex)\n && crate::proxy::providers::is_codex_official_provider(provider)\n}\n\n/// 统一会话开关变更后,立即按新开关状态重写当前官方 Codex 供应商的\n/// live 配置,使开关即时生效(无需等下一次切换)。\n/// 当前供应商非官方(或不存在)时为 no-op:注入只作用于官方配置,\n/// 第三方 live 配置不受开关影响。\npub fn reapply_current_codex_official_live(state: &AppState) -> Result<bool, AppError> {\n let current_id = ProviderService::current(state, AppType::Codex)?;\n if current_id.is_empty() {\n return Ok(false);\n }\n let providers = state.db.get_all_providers(AppType::Codex.as_str())?;\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n if provider.category.as_deref() != Some(\"official\") {\n return Ok(false);\n }\n\n // 代理接管期间 live 归代理所有(开启代理时官方供应商只警告不拦截,\n // 二者可以共存)。与切换/保存路径一致:以 backup/占位符为所有权信号,\n // 只更新备份,注入后的配置由接管释放时的恢复路径落盘。\n let has_live_backup =\n futures::executor::block_on(state.db.get_live_backup(AppType::Codex.as_str()))\n .ok()\n .flatten()\n .is_some();\n let live_taken_over = state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex);\n if has_live_backup || live_taken_over {\n futures::executor::block_on(\n state\n .proxy_service\n .update_live_backup_from_provider(AppType::Codex.as_str(), provider),\n )\n .map_err(|e| AppError::Message(format!(\"更新 Live 备份失败: {e}\")))?;\n return Ok(true);\n }\n\n live::write_live_with_common_config(&state.db, &AppType::Codex, provider)?;\n // 重写 live 会整体替换 config.toml(有意设计),[mcp_servers] 随之丢失,\n // 写完必须立刻从 DB 重新投影启用的 MCP。只投影 Codex 而非\n // sync_all_enabled:后者按 AppType::all() 顺序逐应用短路,排在 Codex\n // 前面的无关应用 live 损坏(如 ~/.claude.json 坏 JSON)会阻断 Codex\n // 的重投影,让刚被清掉的 [mcp_servers] 无人补回。\n // 投影失败降级为警告:走到这里 live 已按新开关状态落盘,开关事实上\n // 已生效;若把错误上抛,save_settings 会回滚开关设置,制造\"设置=旧值、\n // live=新桶\"的会话分裂——正是该回滚要防止的状态。MCP 投影可自愈\n // (下次切换 / 任一 MCP 启停操作都会重新投影)。\n if let Err(err) = McpService::sync_enabled_for_app(state, &AppType::Codex) {\n log::warn!(\"统一会话开关重写 live 后重投影 Codex MCP 失败(将在下次同步时自愈): {err}\");\n }\n Ok(true)\n}\n\n/// Provider business logic service\npub struct ProviderService;\n\n/// Result of a provider switch operation, including any non-fatal warnings\n#[derive(Debug, serde::Serialize, Default)]\n#[serde(rename_all = \"camelCase\")]\npub struct SwitchResult {\n pub warnings: Vec<String>,\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n #[cfg(any(target_os = \"macos\", windows))]\n use crate::claude_desktop_config::PROFILE_ID;\n use crate::config::{get_claude_settings_path, read_json_file, write_json_file};\n use crate::database::Database;\n #[cfg(any(target_os = \"macos\", windows))]\n use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute};\n use crate::provider::{ProviderMeta, UsageScript};\n use crate::proxy::types::ProxyConfig;\n use crate::store::AppState;\n use serde_json::json;\n use serial_test::serial;\n use std::env;\n use std::fs;\n use std::path::{Path, PathBuf};\n use std::sync::{Arc, Mutex, OnceLock};\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: Option<String>,\n #[cfg(windows)]\n original_local_app_data: Option<String>,\n original_userprofile: Option<String>,\n original_test_home: Option<String>,\n }\n\n impl TempHome {\n fn new() -> Self {\n let dir = TempDir::new().expect(\"failed to create temp home\");\n let original_home = env::var(\"HOME\").ok();\n #[cfg(windows)]\n let original_local_app_data = env::var(\"LOCALAPPDATA\").ok();\n let original_userprofile = env::var(\"USERPROFILE\").ok();\n let original_test_home = env::var(\"CC_SWITCH_TEST_HOME\").ok();\n\n env::set_var(\"HOME\", dir.path());\n #[cfg(windows)]\n env::set_var(\"LOCALAPPDATA\", dir.path().join(\"AppData\").join(\"Local\"));\n env::set_var(\"USERPROFILE\", dir.path());\n env::set_var(\"CC_SWITCH_TEST_HOME\", dir.path());\n\n Self {\n dir,\n original_home,\n #[cfg(windows)]\n original_local_app_data,\n original_userprofile,\n original_test_home,\n }\n }\n }\n\n impl Drop for TempHome {\n fn drop(&mut self) {\n match &self.original_home {\n Some(value) => env::set_var(\"HOME\", value),\n None => env::remove_var(\"HOME\"),\n }\n\n #[cfg(windows)]\n {\n match &self.original_local_app_data {\n Some(value) => env::set_var(\"LOCALAPPDATA\", value),\n None => env::remove_var(\"LOCALAPPDATA\"),\n }\n }\n\n match &self.original_userprofile {\n Some(value) => env::set_var(\"USERPROFILE\", value),\n None => env::remove_var(\"USERPROFILE\"),\n }\n\n match &self.original_test_home {\n Some(value) => env::set_var(\"CC_SWITCH_TEST_HOME\", value),\n None => env::remove_var(\"CC_SWITCH_TEST_HOME\"),\n }\n }\n }\n\n #[cfg(windows)]\n fn claude_desktop_profile_path(home: &Path) -> PathBuf {\n home.join(\"AppData\")\n .join(\"Local\")\n .join(\"Claude-3p\")\n .join(\"configLibrary\")\n .join(format!(\"{PROFILE_ID}.json\"))\n }\n\n #[cfg(target_os = \"macos\")]\n fn claude_desktop_profile_path(home: &Path) -> PathBuf {\n home.join(\"Library\")\n .join(\"Application Support\")\n .join(\"Claude-3p\")\n .join(\"configLibrary\")\n .join(format!(\"{PROFILE_ID}.json\"))\n }\n\n fn test_guard() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock<Mutex<()>> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|err| err.into_inner())\n }\n\n fn with_test_home<T>(test: impl FnOnce(&AppState, &Path) -> T) -> T {\n let _guard = test_guard();\n let temp = tempfile::tempdir().expect(\"tempdir\");\n let old_test_home = std::env::var_os(\"CC_SWITCH_TEST_HOME\");\n let old_home = std::env::var_os(\"HOME\");\n std::env::set_var(\"CC_SWITCH_TEST_HOME\", temp.path());\n std::env::set_var(\"HOME\", temp.path());\n\n let db = Arc::new(Database::memory().expect(\"in-memory database\"));\n let state = AppState::new(db);\n let result = test(&state, temp.path());\n\n match old_test_home {\n Some(value) => std::env::set_var(\"CC_SWITCH_TEST_HOME\", value),\n None => std::env::remove_var(\"CC_SWITCH_TEST_HOME\"),\n }\n match old_home {\n Some(value) => std::env::set_var(\"HOME\", value),\n None => std::env::remove_var(\"HOME\"),\n }\n\n result\n }\n\n fn codex_settings(base_url: &str, api_key: &str) -> Value {\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": api_key\n },\n \"config\": format!(\n \"model_provider = \\\"custom\\\"\\n\\\n [model_providers.custom]\\n\\\n name = \\\"custom\\\"\\n\\\n base_url = \\\"{base_url}\\\"\\n\\\n wire_api = \\\"chat\\\"\\n\"\n )\n })\n }\n\n fn usage_script_with_credentials(\n api_key: Option<&str>,\n base_url: Option<&str>,\n template_type: Option<&str>,\n ) -> UsageScript {\n UsageScript {\n enabled: true,\n language: \"javascript\".to_string(),\n code: \"return { remaining: 1, unit: 'USD' };\".to_string(),\n timeout: Some(10),\n api_key: api_key.map(str::to_string),\n base_url: base_url.map(str::to_string),\n access_token: None,\n user_id: None,\n template_type: template_type.map(str::to_string),\n auto_query_interval: None,\n coding_plan_provider: None,\n access_key_id: Some(\"ak-test\".to_string()),\n secret_access_key: Some(\"sk-test\".to_string()),\n team_organization_id: None,\n team_project_id: None,\n }\n }\n\n fn codex_provider_with_usage(\n id: &str,\n base_url: &str,\n api_key: &str,\n usage_api_key: Option<&str>,\n usage_base_url: Option<&str>,\n template_type: Option<&str>,\n ) -> Provider {\n let mut provider = Provider::with_id(\n id.to_string(),\n format!(\"Provider {id}\"),\n codex_settings(base_url, api_key),\n None,\n );\n provider.meta = Some(ProviderMeta {\n usage_script: Some(usage_script_with_credentials(\n usage_api_key,\n usage_base_url,\n template_type,\n )),\n ..Default::default()\n });\n provider\n }\n\n fn openclaw_provider(id: &str) -> Provider {\n Provider {\n id: id.to_string(),\n name: format!(\"Provider {id}\"),\n settings_config: json!({\n \"baseUrl\": \"https://api.deepseek.com\",\n \"apiKey\": \"test-key\",\n \"api\": \"openai-completions\",\n \"models\": [],\n }),\n website_url: None,\n category: Some(\"custom\".to_string()),\n created_at: Some(1),\n sort_index: Some(0),\n notes: None,\n meta: None,\n icon: None,\n icon_color: None,\n in_failover_queue: false,\n }\n }\n\n fn hermes_provider(id: &str) -> Provider {\n Provider {\n id: id.to_string(),\n name: format!(\"Provider {id}\"),\n settings_config: json!({\n \"api\": \"openai-chat\",\n \"base_url\": \"https://api.example.com/v1\",\n \"api_key\": \"test-key\",\n \"models\": {\n \"gpt-4o\": {\n \"name\": \"GPT-4o\"\n }\n }\n }),\n website_url: None,\n category: Some(\"custom\".to_string()),\n created_at: Some(1),\n sort_index: Some(0),\n notes: None,\n meta: None,\n icon: None,\n icon_color: None,\n in_failover_queue: false,\n }\n }\n\n fn opencode_provider(id: &str) -> Provider {\n Provider {\n id: id.to_string(),\n name: format!(\"Provider {id}\"),\n settings_config: json!({\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"name\": format!(\"Provider {id}\"),\n \"options\": {\n \"baseURL\": \"https://api.example.com/v1\",\n \"apiKey\": \"test-key\"\n },\n \"models\": {\n \"gpt-4o\": {\n \"name\": \"GPT-4o\"\n }\n }\n }),\n website_url: None,\n category: Some(\"custom\".to_string()),\n created_at: Some(1),\n sort_index: Some(0),\n notes: None,\n meta: None,\n icon: None,\n icon_color: None,\n in_failover_queue: false,\n }\n }\n\n fn opencode_omo_provider(id: &str, category: &str) -> Provider {\n let mut settings = serde_json::Map::new();\n settings.insert(\n \"agents\".to_string(),\n json!({\n \"writer\": {\n \"model\": \"gpt-4o-mini\"\n }\n }),\n );\n if category == \"omo\" {\n settings.insert(\n \"categories\".to_string(),\n json!({\n \"default\": [\"writer\"]\n }),\n );\n }\n settings.insert(\n \"otherFields\".to_string(),\n json!({\n \"theme\": \"dark\"\n }),\n );\n\n Provider {\n id: id.to_string(),\n name: format!(\"Provider {id}\"),\n settings_config: Value::Object(settings),\n website_url: None,\n category: Some(category.to_string()),\n created_at: Some(1),\n sort_index: Some(0),\n notes: None,\n meta: None,\n icon: None,\n icon_color: None,\n in_failover_queue: false,\n }\n }\n\n fn omo_config_path(home: &Path, category: &str) -> PathBuf {\n home.join(\".config\").join(\"opencode\").join(match category {\n \"omo\" => crate::services::omo::STANDARD.preferred_filename,\n \"omo-slim\" => crate::services::omo::SLIM.preferred_filename,\n other => panic!(\"unexpected OMO category in test: {other}\"),\n })\n }\n\n #[test]\n #[serial]\n fn add_clears_usage_credentials_that_match_provider_config() {\n with_test_home(|state, _| {\n let provider = codex_provider_with_usage(\n \"codex-a\",\n \"https://api.a.example/v1/\",\n \"sk-a\",\n Some(\" sk-a \"),\n Some(\" https://api.a.example/v1/ \"),\n None,\n );\n\n ProviderService::add(state, AppType::Codex, provider, false).expect(\"add provider\");\n\n let saved = state\n .db\n .get_provider_by_id(\"codex-a\", AppType::Codex.as_str())\n .expect(\"query saved provider\")\n .expect(\"saved provider should exist\");\n let script = saved\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script.api_key, None);\n assert_eq!(script.base_url, None);\n });\n }\n\n #[test]\n #[serial]\n fn update_preserves_usage_credentials_that_only_match_previous_config() {\n with_test_home(|state, _| {\n let provider = codex_provider_with_usage(\n \"codex-usage-old\",\n \"https://api.a.example/v1/\",\n \"sk-a\",\n Some(\"sk-a\"),\n Some(\"https://api.a.example/v1/\"),\n None,\n );\n state\n .db\n .save_provider(AppType::Codex.as_str(), &provider)\n .expect(\"seed provider with explicit usage credentials\");\n\n let mut updated = provider.clone();\n updated.settings_config = codex_settings(\"https://api.b.example/v1/\", \"sk-b\");\n\n ProviderService::update(state, AppType::Codex, None, updated)\n .expect(\"update provider main credentials\");\n\n let saved = state\n .db\n .get_provider_by_id(\"codex-usage-old\", AppType::Codex.as_str())\n .expect(\"query updated provider\")\n .expect(\"updated provider should exist\");\n let script = saved\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script.api_key.as_deref(), Some(\"sk-a\"));\n assert_eq!(\n script.base_url.as_deref(),\n Some(\"https://api.a.example/v1/\")\n );\n assert_eq!(\n saved.resolve_usage_credentials(&AppType::Codex),\n (\"https://api.b.example/v1\".to_string(), \"sk-b\".to_string())\n );\n });\n }\n\n #[test]\n #[serial]\n fn copied_provider_uses_edited_credentials_after_add_clears_mirrored_usage_credentials() {\n with_test_home(|state, _| {\n let copied_provider = codex_provider_with_usage(\n \"codex-copy\",\n \"https://api.a.example/v1/\",\n \"sk-a\",\n Some(\"sk-a\"),\n Some(\"https://api.a.example/v1/\"),\n None,\n );\n\n ProviderService::add(state, AppType::Codex, copied_provider, false)\n .expect(\"add copied provider\");\n\n let saved_after_add = state\n .db\n .get_provider_by_id(\"codex-copy\", AppType::Codex.as_str())\n .expect(\"query copied provider\")\n .expect(\"copied provider should exist\");\n let script_after_add = saved_after_add\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n assert_eq!(script_after_add.api_key, None);\n assert_eq!(script_after_add.base_url, None);\n\n let mut edited_provider = saved_after_add.clone();\n edited_provider.settings_config = codex_settings(\"https://api.b.example/v1/\", \"sk-b\");\n\n ProviderService::update(state, AppType::Codex, None, edited_provider)\n .expect(\"edit copied provider credentials\");\n\n let saved_after_update = state\n .db\n .get_provider_by_id(\"codex-copy\", AppType::Codex.as_str())\n .expect(\"query edited provider\")\n .expect(\"edited provider should exist\");\n let script_after_update = saved_after_update\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script_after_update.api_key, None);\n assert_eq!(script_after_update.base_url, None);\n assert_eq!(\n saved_after_update.resolve_usage_credentials(&AppType::Codex),\n (\"https://api.b.example/v1\".to_string(), \"sk-b\".to_string())\n );\n });\n }\n\n #[test]\n #[serial]\n fn update_clears_usage_credentials_that_match_current_config() {\n with_test_home(|state, _| {\n let provider = codex_provider_with_usage(\n \"codex-current\",\n \"https://api.a.example/v1\",\n \"sk-a\",\n Some(\"sk-usage\"),\n Some(\"https://usage.example/api\"),\n None,\n );\n state\n .db\n .save_provider(AppType::Codex.as_str(), &provider)\n .expect(\"seed provider with distinct usage credentials\");\n\n let mut updated = provider.clone();\n updated.settings_config = codex_settings(\"https://api.b.example/v1/\", \"sk-b\");\n updated.meta = Some(ProviderMeta {\n usage_script: Some(usage_script_with_credentials(\n Some(\" sk-b \"),\n Some(\" https://api.b.example/v1/ \"),\n None,\n )),\n ..Default::default()\n });\n\n ProviderService::update(state, AppType::Codex, None, updated)\n .expect(\"update provider with redundant usage credentials\");\n\n let saved = state\n .db\n .get_provider_by_id(\"codex-current\", AppType::Codex.as_str())\n .expect(\"query updated provider\")\n .expect(\"updated provider should exist\");\n let script = saved\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script.api_key, None);\n assert_eq!(script.base_url, None);\n });\n }\n\n #[test]\n #[serial]\n fn add_preserves_distinct_usage_credentials() {\n with_test_home(|state, _| {\n let provider = codex_provider_with_usage(\n \"codex-distinct\",\n \"https://api.main.example/v1\",\n \"sk-main\",\n Some(\"sk-usage\"),\n Some(\"https://usage.example/api\"),\n None,\n );\n\n ProviderService::add(state, AppType::Codex, provider, false).expect(\"add provider\");\n\n let saved = state\n .db\n .get_provider_by_id(\"codex-distinct\", AppType::Codex.as_str())\n .expect(\"query saved provider\")\n .expect(\"saved provider should exist\");\n let script = saved\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script.api_key.as_deref(), Some(\"sk-usage\"));\n assert_eq!(\n script.base_url.as_deref(),\n Some(\"https://usage.example/api\")\n );\n });\n }\n\n #[test]\n #[serial]\n fn add_does_not_clear_token_plan_credentials() {\n with_test_home(|state, _| {\n let provider = codex_provider_with_usage(\n \"codex-token-plan\",\n \"https://api.plan.example/v1\",\n \"sk-plan\",\n Some(\"sk-plan\"),\n Some(\"https://api.plan.example/v1\"),\n Some(\"token_plan\"),\n );\n\n ProviderService::add(state, AppType::Codex, provider, false).expect(\"add provider\");\n\n let saved = state\n .db\n .get_provider_by_id(\"codex-token-plan\", AppType::Codex.as_str())\n .expect(\"query saved provider\")\n .expect(\"saved provider should exist\");\n let script = saved\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script.api_key.as_deref(), Some(\"sk-plan\"));\n assert_eq!(\n script.base_url.as_deref(),\n Some(\"https://api.plan.example/v1\")\n );\n assert_eq!(script.access_key_id.as_deref(), Some(\"ak-test\"));\n assert_eq!(script.secret_access_key.as_deref(), Some(\"sk-test\"));\n });\n }\n\n #[test]\n fn validate_provider_settings_rejects_missing_auth() {\n let provider = Provider::with_id(\n \"codex\".into(),\n \"Codex\".into(),\n json!({ \"config\": \"base_url = \\\"https://example.com\\\"\" }),\n None,\n );\n let err = ProviderService::validate_provider_settings(&AppType::Codex, &provider)\n .expect_err(\"missing auth should be rejected\");\n assert!(\n err.to_string().contains(\"auth\"),\n \"expected auth error, got {err:?}\"\n );\n }\n\n #[test]\n fn extract_claude_common_config_strips_all_credentials_keeps_shareable() {\n // env 混入多种凭据(Anthropic/OpenRouter/Google/OpenAI/Gemini + AWS/Vertex)\n // 与可共享配置;顶层混入非标准的 apiKey/api_key 凭据与正常设置。\n let settings = json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"sk-ant\",\n \"ANTHROPIC_AUTH_TOKEN\": \"tok-ant\",\n \"OPENROUTER_API_KEY\": \"sk-or\",\n \"GOOGLE_API_KEY\": \"g-key\",\n \"OPENAI_API_KEY\": \"sk-oai\",\n \"GEMINI_API_KEY\": \"g-gem\",\n \"AWS_ACCESS_KEY_ID\": \"AKIA\",\n \"AWS_SECRET_ACCESS_KEY\": \"secret\",\n \"AWS_SESSION_TOKEN\": \"sess\",\n \"GOOGLE_APPLICATION_CREDENTIALS\": \"/path/creds.json\",\n \"AWS_BEARER_TOKEN_BEDROCK\": \"bedrock-tok\",\n \"ANTHROPIC_BASE_URL\": \"https://example.com\",\n \"ANTHROPIC_MODEL\": \"claude-x\",\n \"CLAUDE_CODE_SUBAGENT_MODEL\": \"gpt-5.4-mini\",\n \"CLAUDE_CODE_MAX_CONTEXT_TOKENS\": \"400000\",\n \"CLAUDE_CODE_AUTO_COMPACT_WINDOW\": \"400000\",\n // 可共享、非机密配置(复数 _TOKENS 不应被误剥)\n \"ENABLE_TOOL_SEARCH\": \"true\",\n \"CLAUDE_CODE_MAX_OUTPUT_TOKENS\": \"8192\"\n },\n \"apiKey\": \"sk-top\",\n \"api_key\": \"sk-top2\",\n \"theme\": \"dark\",\n \"includeCoAuthoredBy\": false\n });\n\n let snippet = ProviderService::extract_claude_common_config(&settings)\n .expect(\"extract should succeed\");\n let value: Value = serde_json::from_str(&snippet).expect(\"snippet is valid JSON\");\n\n // 所有凭据都不得出现在共享片段里\n let env = value.get(\"env\");\n for leaked in [\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_AUTH_TOKEN\",\n \"OPENROUTER_API_KEY\",\n \"GOOGLE_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"AWS_ACCESS_KEY_ID\",\n \"AWS_SECRET_ACCESS_KEY\",\n \"AWS_SESSION_TOKEN\",\n \"GOOGLE_APPLICATION_CREDENTIALS\",\n \"AWS_BEARER_TOKEN_BEDROCK\",\n ] {\n assert!(\n env.and_then(|e| e.get(leaked)).is_none(),\n \"credential {leaked} must not leak into common config\"\n );\n }\n assert!(\n value.get(\"apiKey\").is_none() && value.get(\"api_key\").is_none(),\n \"top-level credentials must be stripped\"\n );\n\n // 端点/模型(provider-specific 非机密)也应剥掉\n assert!(env.and_then(|e| e.get(\"ANTHROPIC_BASE_URL\")).is_none());\n assert!(env.and_then(|e| e.get(\"ANTHROPIC_MODEL\")).is_none());\n assert!(env\n .and_then(|e| e.get(\"CLAUDE_CODE_SUBAGENT_MODEL\"))\n .is_none());\n assert!(env\n .and_then(|e| e.get(\"CLAUDE_CODE_MAX_CONTEXT_TOKENS\"))\n .is_none());\n assert!(env\n .and_then(|e| e.get(\"CLAUDE_CODE_AUTO_COMPACT_WINDOW\"))\n .is_none());\n\n // 可共享的非机密配置必须保留(含复数 _TOKENS 不被误剥)\n assert_eq!(\n env.and_then(|e| e.get(\"ENABLE_TOOL_SEARCH\"))\n .and_then(|v| v.as_str()),\n Some(\"true\")\n );\n assert_eq!(\n env.and_then(|e| e.get(\"CLAUDE_CODE_MAX_OUTPUT_TOKENS\"))\n .and_then(|v| v.as_str()),\n Some(\"8192\")\n );\n assert_eq!(value.get(\"theme\").and_then(|v| v.as_str()), Some(\"dark\"));\n assert_eq!(value.get(\"includeCoAuthoredBy\"), Some(&json!(false)));\n }\n\n /// Regression for issue #4272: Fable tier env keys must not enter the shared\n /// Claude common-config snippet (same class as haiku/sonnet/opus model pins).\n #[test]\n fn extract_claude_common_config_strips_fable_model_env_keys() {\n let settings = json!({\n \"env\": {\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"haiku-mapped\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Haiku Mapped\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"sonnet-mapped[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Sonnet Mapped\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"opus-mapped[1M]\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Opus Mapped\",\n \"ANTHROPIC_DEFAULT_FABLE_MODEL\": \"deepseek-v4-flash[1M]\",\n \"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME\": \"deepseek-v4-flash\",\n \"ANTHROPIC_MODEL\": \"default-mapped\",\n \"ENABLE_TOOL_SEARCH\": \"true\"\n },\n \"theme\": \"dark\"\n });\n\n let snippet = ProviderService::extract_claude_common_config(&settings)\n .expect(\"extract should succeed\");\n let value: Value = serde_json::from_str(&snippet).expect(\"snippet is valid JSON\");\n let env = value.get(\"env\");\n\n for stripped in [\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n \"ANTHROPIC_DEFAULT_FABLE_MODEL\",\n \"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME\",\n \"ANTHROPIC_MODEL\",\n ] {\n assert!(\n env.and_then(|e| e.get(stripped)).is_none(),\n \"provider-specific model key {stripped} must not enter common config\"\n );\n }\n\n assert_eq!(\n env.and_then(|e| e.get(\"ENABLE_TOOL_SEARCH\"))\n .and_then(|v| v.as_str()),\n Some(\"true\")\n );\n assert_eq!(value.get(\"theme\").and_then(|v| v.as_str()), Some(\"dark\"));\n }\n\n #[test]\n fn validate_provider_settings_rejects_negative_cost_multiplier() {\n let mut provider = Provider::with_id(\n \"claude\".into(),\n \"Claude\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"token\",\n \"ANTHROPIC_BASE_URL\": \"https://claude.example\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n cost_multiplier: Some(\"-1\".to_string()),\n ..ProviderMeta::default()\n });\n\n let err = ProviderService::validate_provider_settings(&AppType::Claude, &provider)\n .expect_err(\"negative multiplier should be rejected\");\n assert!(matches!(\n err,\n AppError::Localized {\n key: \"error.invalidMultiplier\",\n ..\n }\n ));\n }\n\n #[test]\n fn extract_credentials_returns_expected_values() {\n let provider = Provider::with_id(\n \"claude\".into(),\n \"Claude\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"token\",\n \"ANTHROPIC_BASE_URL\": \"https://claude.example\"\n }\n }),\n None,\n );\n let (api_key, base_url) =\n ProviderService::extract_credentials(&provider, &AppType::Claude).unwrap();\n assert_eq!(api_key, \"token\");\n assert_eq!(base_url, \"https://claude.example\");\n }\n\n #[test]\n fn extract_codex_common_config_strips_provider_fields_and_injected_artifacts() {\n // 顶层 experimental_bearer_token 模拟无活跃路由时的 fallback 注入;\n // web_search = \"disabled\" 是 cc-switch 对黑名单网关注入的哨兵;\n // 顶层 wire_api 模拟无 model_provider 时的 fallback 写法;\n // [mcp.servers] 是历史错误格式,sync_all_enabled 清不掉它。\n let config_toml = r#\"model_provider = \"azure\"\nmodel = \"gpt-4\"\nwire_api = \"chat\"\ndisable_response_storage = true\nexperimental_bearer_token = \"sk-live-secret\"\nmodel_catalog_json = \"cc-switch-model-catalog.json\"\nweb_search = \"disabled\"\n\n[model_providers.azure]\nname = \"Azure OpenAI\"\nbase_url = \"https://azure.example/v1\"\nwire_api = \"responses\"\n\n[mcp_servers.my_server]\nbase_url = \"http://localhost:8080\"\n\n[mcp.servers.legacy_server]\ncommand = \"legacy-cmd\"\n\"#;\n\n let settings = json!({ \"config\": config_toml });\n let extracted = ProviderService::extract_codex_common_config(&settings)\n .expect(\"extract_codex_common_config should succeed\");\n\n assert!(\n !extracted\n .lines()\n .any(|line| line.trim_start().starts_with(\"model_provider\")),\n \"should remove top-level model_provider\"\n );\n assert!(\n !extracted\n .lines()\n .any(|line| line.trim_start().starts_with(\"model =\")),\n \"should remove top-level model\"\n );\n assert!(\n !extracted.contains(\"[model_providers\"),\n \"should remove entire model_providers table\"\n );\n // MCP 归 DB mcp_servers 表所有,不得进共享片段(含历史错误格式 [mcp.servers])\n assert!(\n !extracted.contains(\"mcp_servers\") && !extracted.contains(\"http://localhost:8080\"),\n \"should strip mcp_servers from the shared snippet, got: {extracted}\"\n );\n assert!(\n !extracted.contains(\"[mcp\") && !extracted.contains(\"legacy-cmd\"),\n \"should strip the legacy [mcp.servers] form from the shared snippet, got: {extracted}\"\n );\n // 顶层 wire_api 是供应商路由语义(model_providers 整表已剥,\n // 剩余任何 wire_api 都意味着泄漏)\n assert!(\n !extracted.contains(\"wire_api\"),\n \"should strip top-level wire_api from the shared snippet, got: {extracted}\"\n );\n // 注入产物不得进共享片段(bearer token 泄漏为密钥级问题)\n assert!(\n !extracted.contains(\"experimental_bearer_token\")\n && !extracted.contains(\"sk-live-secret\"),\n \"should strip top-level fallback bearer token, got: {extracted}\"\n );\n assert!(\n !extracted.contains(\"model_catalog_json\"),\n \"should strip catalog projection pointer, got: {extracted}\"\n );\n assert!(\n !extracted.contains(\"web_search\"),\n \"should strip the cc-switch web_search disabled sentinel, got: {extracted}\"\n );\n // 真正可共享的键保留\n assert!(\n extracted.contains(\"disable_response_storage = true\"),\n \"shareable keys must survive extraction, got: {extracted}\"\n );\n }\n\n #[test]\n fn extract_codex_common_config_keeps_user_set_web_search() {\n let config_toml = \"web_search = \\\"enabled\\\"\\ndisable_response_storage = true\\n\";\n let settings = json!({ \"config\": config_toml });\n let extracted = ProviderService::extract_codex_common_config(&settings)\n .expect(\"extract should succeed\");\n assert!(\n extracted.contains(\"web_search = \\\"enabled\\\"\"),\n \"a user-set web_search value is a shareable preference, got: {extracted}\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_current_claude_provider_syncs_live_when_proxy_takeover_detected_without_backup()\n {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = AppState::new(db.clone());\n\n let original = Provider::with_id(\n \"p1\".into(),\n \"Claude A\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"token-a\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"model-a\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n db.save_provider(\"claude\", &original)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"p1\"))\n .expect(\"set local current provider\");\n\n db.update_proxy_config(ProxyConfig {\n live_takeover_active: true,\n listen_port: 0,\n ..Default::default()\n })\n .await\n .expect(\"update proxy config\");\n {\n let mut config = db\n .get_proxy_config_for_app(\"claude\")\n .await\n .expect(\"get app proxy config\");\n config.enabled = true;\n db.update_proxy_config_for_app(config)\n .await\n .expect(\"update app proxy config\");\n }\n\n write_json_file(\n &get_claude_settings_path(),\n &json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": \"PROXY_MANAGED\",\n \"ANTHROPIC_MODEL\": \"stale-model\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n )\n .expect(\"seed taken-over live file\");\n\n let proxy_info = state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy service\");\n\n let updated = Provider::with_id(\n \"p1\".into(),\n \"Claude A\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"token-updated\",\n \"ANTHROPIC_BASE_URL\": \"https://api.updated.example\",\n \"ANTHROPIC_MODEL\": \"model-updated\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n ProviderService::update(&state, AppType::Claude, None, updated.clone())\n .expect(\"update current provider\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored_provider = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get stored provider\")\n .expect(\"stored provider exists\");\n let expected_backup =\n serde_json::to_string(&stored_provider.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected_backup);\n\n let live: Value = read_json_file(&get_claude_settings_path()).expect(\"read live\");\n assert_eq!(\n live.get(\"permissions\"),\n updated.settings_config.get(\"permissions\"),\n \"provider edits should propagate into Claude live config during takeover\"\n );\n assert_eq!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"PROXY_MANAGED\"),\n \"takeover placeholder should stay intact\"\n );\n assert_eq!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_BASE_URL\"))\n .and_then(|v| v.as_str()),\n Some(format!(\"http://127.0.0.1:{}\", proxy_info.port).as_str()),\n \"proxy base URL should stay intact\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"model override should be removed in takeover live config\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_current_codex_provider_refreshes_and_clears_catalog_during_takeover() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = AppState::new(db.clone());\n\n let mut original = Provider::with_id(\n \"p1\".into(),\n \"Codex A\".into(),\n json!({\n \"auth\": { \"OPENAI_API_KEY\": \"token-a\" },\n \"config\": r#\"model_provider = \"custom\"\nmodel = \"old-model\"\n\n[model_providers.custom]\nname = \"Codex A\"\nbase_url = \"https://api.a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#,\n \"modelCatalog\": {\n \"models\": [{ \"model\": \"old-model\" }]\n }\n }),\n None,\n );\n original.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".into()),\n ..Default::default()\n });\n db.save_provider(\"codex\", &original).expect(\"save provider\");\n db.set_current_provider(\"codex\", \"p1\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"p1\"))\n .expect(\"set local current provider\");\n\n db.update_proxy_config(ProxyConfig {\n live_takeover_active: true,\n listen_port: 0,\n ..Default::default()\n })\n .await\n .expect(\"update proxy config\");\n {\n let mut config = db\n .get_proxy_config_for_app(\"codex\")\n .await\n .expect(\"get app proxy config\");\n config.enabled = true;\n db.update_proxy_config_for_app(config)\n .await\n .expect(\"enable Codex proxy config\");\n }\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&original.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy service\");\n state\n .proxy_service\n .sync_codex_live_from_provider_while_proxy_active(&original)\n .await\n .expect(\"seed taken-over Codex live config\");\n assert!(\n state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded Codex live config should be recognized as takeover-owned\"\n );\n\n let mut updated = original.clone();\n updated.settings_config[\"config\"] = json!(\n r#\"model_provider = \"custom\"\nmodel = \"gpt-5.4\"\n\n[model_providers.custom]\nname = \"Codex A\"\nbase_url = \"https://api.updated.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n );\n updated.settings_config[\"modelCatalog\"] = json!({\n \"models\": [{ \"model\": \"gpt-5.4\", \"displayName\": \"GPT 5.4\" }]\n });\n\n ProviderService::update(&state, AppType::Codex, None, updated.clone())\n .expect(\"update current Codex provider mapping\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n let catalog: Value = read_json_file(&catalog_path).expect(\"read generated catalog\");\n assert_eq!(catalog[\"models\"][0][\"slug\"], \"gpt-5.4\");\n assert_eq!(\n catalog[\"models\"][0][\"input_modalities\"],\n json!([\"text\", \"image\"]),\n \"unknown/GPT models must fail open to image input\"\n );\n let live_config = fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read Codex config.toml\");\n assert!(live_config.contains(\"model_catalog_json\"));\n\n updated.settings_config[\"modelCatalog\"] = json!({ \"models\": [] });\n ProviderService::update(&state, AppType::Codex, None, updated)\n .expect(\"remove current Codex provider mapping\");\n\n let live_config = fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read Codex config.toml after mapping removal\");\n assert!(\n !live_config.contains(\"model_catalog_json\"),\n \"removing mappings during takeover must clear the stale catalog pointer\"\n );\n\n state\n .proxy_service\n .stop()\n .await\n .expect(\"stop proxy service\");\n }\n\n #[cfg(any(target_os = \"macos\", windows))]\n #[tokio::test]\n #[serial]\n async fn update_current_claude_desktop_provider_syncs_profile_when_proxy_takeover_is_active() {\n let home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = AppState::new(db.clone());\n\n let mut original = Provider::with_id(\n \"p1\".into(),\n \"Desktop A\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"token-a\",\n \"ANTHROPIC_BASE_URL\": \"https://opencode.ai/zen/go\"\n }\n }),\n None,\n );\n original.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".into()),\n claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),\n claude_desktop_model_routes: std::collections::HashMap::from([(\n \"claude-sonnet-4-6\".into(),\n ClaudeDesktopModelRoute {\n model: \"deepseek-v4-flash\".into(),\n label_override: Some(\"DeepSeek V4 Flash\".into()),\n supports_1m: None,\n },\n )]),\n ..Default::default()\n });\n db.save_provider(\"claude-desktop\", &original)\n .expect(\"save provider\");\n db.set_current_provider(\"claude-desktop\", \"p1\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::ClaudeDesktop, Some(\"p1\"))\n .expect(\"set local current provider\");\n\n // Claude Desktop keeps backup state from takeover startup; this sentinel only\n // marks takeover as active so provider updates rewrite the 3P profile.\n db.save_live_backup(\"claude-desktop\", \"{}\")\n .await\n .expect(\"seed live backup\");\n {\n let mut config = db\n .get_proxy_config_for_app(\"claude-desktop\")\n .await\n .expect(\"get app proxy config\");\n config.enabled = true;\n db.update_proxy_config_for_app(config)\n .await\n .expect(\"update app proxy config\");\n }\n\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy service\");\n\n let mut updated = Provider::with_id(\n \"p1\".into(),\n \"Desktop A\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"token-updated\",\n \"ANTHROPIC_BASE_URL\": \"https://opencode.ai/zen/go\"\n }\n }),\n None,\n );\n updated.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".into()),\n claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),\n claude_desktop_model_routes: std::collections::HashMap::from([(\n \"claude-sonnet-4-6\".into(),\n ClaudeDesktopModelRoute {\n model: \"deepseek-v4-flash\".into(),\n label_override: Some(\"DeepSeek V4 Flash Updated\".into()),\n supports_1m: Some(true),\n },\n )]),\n ..Default::default()\n });\n\n ProviderService::update(&state, AppType::ClaudeDesktop, None, updated.clone())\n .expect(\"update current provider\");\n\n let backup = db\n .get_live_backup(\"claude-desktop\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n assert_eq!(\n backup.original_config, \"{}\",\n \"Claude Desktop provider edits should not rewrite takeover backup\"\n );\n\n let profile_path = claude_desktop_profile_path(home.dir.path());\n let profile: Value = read_json_file(&profile_path).expect(\"read desktop profile\");\n assert_eq!(\n profile[\"inferenceGatewayBaseUrl\"],\n json!(\"http://127.0.0.1:15721/claude-desktop\"),\n \"desktop profile should stay pointed at the local gateway during takeover\"\n );\n assert_eq!(profile[\"inferenceGatewayAuthScheme\"], json!(\"bearer\"));\n assert_eq!(\n profile[\"inferenceModels\"],\n json!([{ \"name\": \"claude-sonnet-4-6\", \"labelOverride\": \"DeepSeek V4 Flash Updated\", \"supports1m\": true }]),\n \"provider edits should propagate into the Claude Desktop 3P profile during takeover\"\n );\n }\n\n #[test]\n #[serial]\n fn rename_rejects_missing_original_provider() {\n with_test_home(|state, _| {\n let original = openclaw_provider(\"deepseek\");\n ProviderService::add(state, AppType::OpenClaw, original.clone(), false)\n .expect(\"seed db-only provider\");\n\n let mut renamed = original.clone();\n renamed.id = \"deepseek-copy\".to_string();\n\n let err = ProviderService::update(\n state,\n AppType::OpenClaw,\n Some(\"missing-provider\"),\n renamed,\n )\n .expect_err(\"stale originalId should be rejected\");\n\n assert!(\n err.to_string().contains(\"Original provider\"),\n \"expected missing original provider error, got {err:?}\"\n );\n assert!(\n state\n .db\n .get_provider_by_id(\"deepseek-copy\", AppType::OpenClaw.as_str())\n .expect(\"query renamed provider\")\n .is_none(),\n \"rename must not create a new row when originalId is stale\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn db_only_additive_update_survives_live_config_parse_errors() {\n with_test_home(|state, home| {\n let provider = openclaw_provider(\"deepseek\");\n ProviderService::add(state, AppType::OpenClaw, provider.clone(), false)\n .expect(\"seed db-only provider\");\n\n let stored = state\n .db\n .get_provider_by_id(\"deepseek\", AppType::OpenClaw.as_str())\n .expect(\"query stored provider\")\n .expect(\"provider should exist\");\n assert_eq!(\n stored\n .meta\n .as_ref()\n .and_then(|meta| meta.live_config_managed),\n Some(false),\n \"db-only provider should be marked as not live-managed\"\n );\n\n let openclaw_dir = home.join(\".openclaw\");\n fs::create_dir_all(&openclaw_dir).expect(\"create openclaw dir\");\n fs::write(openclaw_dir.join(\"openclaw.json\"), \"{ invalid json5\")\n .expect(\"write malformed config\");\n\n let mut updated = stored.clone();\n updated.name = \"DeepSeek Edited\".to_string();\n updated.meta.get_or_insert_with(ProviderMeta::default);\n\n ProviderService::update(state, AppType::OpenClaw, None, updated)\n .expect(\"db-only update should ignore live parse errors\");\n\n let saved = state\n .db\n .get_provider_by_id(\"deepseek\", AppType::OpenClaw.as_str())\n .expect(\"query updated provider\")\n .expect(\"updated provider should exist\");\n assert_eq!(saved.name, \"DeepSeek Edited\");\n });\n }\n\n #[test]\n #[serial]\n fn sync_current_provider_for_app_skips_db_only_opencode_provider() {\n with_test_home(|state, _| {\n let provider = opencode_provider(\"db-only-opencode\");\n ProviderService::add(state, AppType::OpenCode, provider.clone(), false)\n .expect(\"seed db-only opencode provider\");\n\n ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)\n .expect(\"sync additive opencode providers\");\n\n let live_providers = crate::opencode_config::get_providers()\n .expect(\"read opencode providers after sync\");\n assert!(\n !live_providers.contains_key(&provider.id),\n \"db-only opencode provider should not be written to live during sync\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn sync_current_provider_for_app_skips_db_only_openclaw_provider() {\n with_test_home(|state, _| {\n let provider = openclaw_provider(\"db-only-openclaw\");\n ProviderService::add(state, AppType::OpenClaw, provider.clone(), false)\n .expect(\"seed db-only openclaw provider\");\n\n ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw)\n .expect(\"sync additive openclaw providers\");\n\n let live_providers = crate::openclaw_config::get_providers()\n .expect(\"read openclaw providers after sync\");\n assert!(\n !live_providers.contains_key(&provider.id),\n \"db-only openclaw provider should not be written to live during sync\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn sync_current_provider_for_app_preserves_legacy_live_opencode_provider() {\n with_test_home(|state, _| {\n let provider = opencode_provider(\"legacy-opencode\");\n crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone())\n .expect(\"seed opencode live provider\");\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .expect(\"seed legacy opencode provider in db\");\n\n let mut updated = provider.clone();\n updated.settings_config[\"options\"][\"apiKey\"] = Value::String(\"updated-key\".to_string());\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &updated)\n .expect(\"update legacy opencode provider in db\");\n\n ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)\n .expect(\"sync legacy opencode provider\");\n\n let live_providers =\n crate::opencode_config::get_providers().expect(\"read opencode providers\");\n assert_eq!(\n live_providers\n .get(&provider.id)\n .and_then(|config| config.get(\"options\"))\n .and_then(|options| options.get(\"apiKey\")),\n Some(&Value::String(\"updated-key\".to_string())),\n \"legacy provider that already exists in live should still be synced\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn sync_current_provider_for_app_restores_legacy_opencode_provider_after_live_reset() {\n with_test_home(|state, _| {\n let provider = opencode_provider(\"legacy-opencode-reset\");\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .expect(\"seed legacy opencode provider in db\");\n\n ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)\n .expect(\"sync legacy opencode provider after reset\");\n\n let live_providers =\n crate::opencode_config::get_providers().expect(\"read opencode providers\");\n assert!(\n live_providers.contains_key(&provider.id),\n \"legacy opencode provider should be restored when live config is reset\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn sync_current_provider_for_app_restores_legacy_openclaw_provider_after_live_reset() {\n with_test_home(|state, _| {\n let mut provider = openclaw_provider(\"legacy-openclaw-reset\");\n provider.settings_config[\"models\"] = json!([\n {\n \"id\": \"claude-sonnet-4\",\n \"name\": \"Claude Sonnet 4\"\n }\n ]);\n state\n .db\n .save_provider(AppType::OpenClaw.as_str(), &provider)\n .expect(\"seed legacy openclaw provider in db\");\n\n ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw)\n .expect(\"sync legacy openclaw provider after reset\");\n\n let live_providers =\n crate::openclaw_config::get_providers().expect(\"read openclaw providers\");\n assert!(\n live_providers.contains_key(&provider.id),\n \"legacy openclaw provider should be restored when live config is reset\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn import_opencode_providers_from_live_marks_provider_as_live_managed() {\n with_test_home(|state, _| {\n let provider = opencode_provider(\"imported-opencode\");\n crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone())\n .expect(\"seed opencode live provider\");\n\n let imported = import_opencode_providers_from_live(state)\n .expect(\"import opencode providers from live\");\n assert_eq!(imported, 1);\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .expect(\"query imported opencode provider\")\n .expect(\"imported opencode provider should exist\");\n assert_eq!(\n saved\n .meta\n .as_ref()\n .and_then(|meta| meta.live_config_managed),\n Some(true),\n \"providers imported from live should be treated as live-managed\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn import_opencode_providers_from_live_updates_existing_provider_from_live() {\n with_test_home(|state, _| {\n let provider = opencode_provider(\"existing-opencode\");\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .expect(\"seed existing opencode provider\");\n\n let mut live_settings = provider.settings_config.clone();\n live_settings.as_object_mut().unwrap().remove(\"name\");\n live_settings[\"npm\"] = Value::String(\"@ai-sdk/anthropic\".to_string());\n live_settings[\"models\"][\"gpt-4o\"][\"name\"] = Value::String(\"Claude Sonnet\".to_string());\n crate::opencode_config::set_provider(&provider.id, live_settings)\n .expect(\"seed edited live opencode provider\");\n\n let updated = import_opencode_providers_from_live(state)\n .expect(\"import opencode providers from live\");\n assert_eq!(updated, 1);\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .expect(\"query updated opencode provider\")\n .expect(\"opencode provider should exist\");\n assert_eq!(saved.name, provider.name);\n assert_eq!(saved.settings_config[\"npm\"], json!(\"@ai-sdk/anthropic\"));\n assert_eq!(\n saved.settings_config[\"models\"][\"gpt-4o\"][\"name\"],\n json!(\"Claude Sonnet\")\n );\n });\n }\n #[test]\n #[serial]\n fn import_openclaw_providers_from_live_marks_provider_as_live_managed() {\n with_test_home(|state, _| {\n let mut provider = openclaw_provider(\"imported-openclaw\");\n provider.settings_config[\"models\"] = json!([\n {\n \"id\": \"claude-sonnet-4\",\n \"name\": \"Claude Sonnet 4\"\n }\n ]);\n crate::openclaw_config::set_provider(&provider.id, provider.settings_config.clone())\n .expect(\"seed openclaw live provider\");\n\n let imported = import_openclaw_providers_from_live(state)\n .expect(\"import openclaw providers from live\");\n assert_eq!(imported, 1);\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenClaw.as_str())\n .expect(\"query imported openclaw provider\")\n .expect(\"imported openclaw provider should exist\");\n assert_eq!(\n saved\n .meta\n .as_ref()\n .and_then(|meta| meta.live_config_managed),\n Some(true),\n \"providers imported from live should be treated as live-managed\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn import_openclaw_providers_from_live_updates_existing_provider_from_live() {\n with_test_home(|state, _| {\n let mut provider = openclaw_provider(\"existing-openclaw\");\n provider.settings_config[\"models\"] = json!([\n {\n \"id\": \"claude-sonnet-4\",\n \"name\": \"Claude Sonnet 4\"\n }\n ]);\n state\n .db\n .save_provider(AppType::OpenClaw.as_str(), &provider)\n .expect(\"seed existing openclaw provider\");\n\n let mut live_settings = provider.settings_config.clone();\n live_settings[\"baseUrl\"] = Value::String(\"https://api.example.com/v1\".to_string());\n live_settings[\"models\"][0][\"name\"] = Value::String(\"Claude Sonnet 4.1\".to_string());\n crate::openclaw_config::set_provider(&provider.id, live_settings)\n .expect(\"seed edited live openclaw provider\");\n\n let updated = import_openclaw_providers_from_live(state)\n .expect(\"import openclaw providers from live\");\n assert_eq!(updated, 1);\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenClaw.as_str())\n .expect(\"query updated openclaw provider\")\n .expect(\"openclaw provider should exist\");\n assert_eq!(saved.name, provider.name);\n assert_eq!(\n saved.settings_config[\"baseUrl\"],\n json!(\"https://api.example.com/v1\")\n );\n assert_eq!(\n saved.settings_config[\"models\"][0][\"name\"],\n json!(\"Claude Sonnet 4.1\")\n );\n });\n }\n\n #[test]\n #[serial]\n fn import_hermes_providers_from_live_updates_existing_provider_from_live() {\n with_test_home(|state, _| {\n let provider = hermes_provider(\"existing-hermes\");\n state\n .db\n .save_provider(AppType::Hermes.as_str(), &provider)\n .expect(\"seed existing hermes provider\");\n\n let mut live_settings = provider.settings_config.clone();\n live_settings[\"base_url\"] = Value::String(\"https://api.hermes.example/v1\".to_string());\n live_settings[\"models\"][\"gpt-4o\"][\"name\"] = Value::String(\"GPT-4o Updated\".to_string());\n crate::hermes_config::set_provider(&provider.id, live_settings)\n .expect(\"seed edited live hermes provider\");\n\n let updated = import_hermes_providers_from_live(state)\n .expect(\"import hermes providers from live\");\n assert_eq!(updated, 1);\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::Hermes.as_str())\n .expect(\"query updated hermes provider\")\n .expect(\"hermes provider should exist\");\n assert_eq!(saved.name, provider.name);\n assert_eq!(\n saved.settings_config[\"base_url\"],\n json!(\"https://api.hermes.example/v1\")\n );\n // models are denormalized from YAML dict to UI-friendly array by\n // get_providers(), so access by index rather than dict key\n assert_eq!(\n saved.settings_config[\"models\"][0][\"name\"],\n json!(\"GPT-4o Updated\")\n );\n assert_eq!(saved.settings_config[\"models\"][0][\"id\"], json!(\"gpt-4o\"));\n });\n }\n\n #[test]\n #[serial]\n fn legacy_additive_provider_still_errors_on_live_config_parse_failure() {\n with_test_home(|state, home| {\n let provider = openclaw_provider(\"legacy-provider\");\n state\n .db\n .save_provider(AppType::OpenClaw.as_str(), &provider)\n .expect(\"seed legacy provider without live_config_managed marker\");\n\n let openclaw_dir = home.join(\".openclaw\");\n fs::create_dir_all(&openclaw_dir).expect(\"create openclaw dir\");\n fs::write(openclaw_dir.join(\"openclaw.json\"), \"{ invalid json5\")\n .expect(\"write malformed config\");\n\n let mut updated = provider.clone();\n updated.name = \"Legacy Edited\".to_string();\n\n let err = ProviderService::update(state, AppType::OpenClaw, None, updated)\n .expect_err(\"legacy providers should still surface live parse errors\");\n assert!(\n err.to_string().contains(\"Failed to parse OpenClaw config\"),\n \"expected parse error, got {err:?}\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn update_persists_non_current_omo_variants_in_database() {\n with_test_home(|state, _| {\n for category in [\"omo\", \"omo-slim\"] {\n let provider = opencode_omo_provider(&format!(\"{category}-provider\"), category);\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .unwrap_or_else(|err| panic!(\"seed {category} provider: {err}\"));\n\n let mut updated = provider.clone();\n updated.name = format!(\"Updated {category}\");\n updated.settings_config[\"agents\"][\"writer\"][\"model\"] =\n Value::String(format!(\"{category}-next-model\"));\n\n ProviderService::update(state, AppType::OpenCode, None, updated)\n .unwrap_or_else(|err| panic!(\"update {category} provider: {err}\"));\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .unwrap_or_else(|err| panic!(\"query updated {category} provider: {err}\"))\n .unwrap_or_else(|| panic!(\"{category} provider should exist\"));\n\n assert_eq!(saved.name, format!(\"Updated {category}\"));\n assert_eq!(\n saved.settings_config[\"agents\"][\"writer\"][\"model\"],\n Value::String(format!(\"{category}-next-model\")),\n \"{category} updates should persist in the database\"\n );\n }\n });\n }\n\n #[test]\n #[serial]\n fn update_current_omo_variant_rewrites_config_from_saved_provider() {\n with_test_home(|state, home| {\n for category in [\"omo\", \"omo-slim\"] {\n let provider = opencode_omo_provider(&format!(\"{category}-current\"), category);\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .unwrap_or_else(|err| panic!(\"seed current {category} provider: {err}\"));\n state\n .db\n .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, category)\n .unwrap_or_else(|err| panic!(\"set current {category} provider: {err}\"));\n\n let mut updated = provider.clone();\n updated.name = format!(\"Current {category} updated\");\n updated.settings_config[\"agents\"][\"writer\"][\"model\"] =\n Value::String(format!(\"{category}-saved-model\"));\n updated.settings_config[\"otherFields\"][\"theme\"] =\n Value::String(format!(\"{category}-light\"));\n\n ProviderService::update(state, AppType::OpenCode, None, updated)\n .unwrap_or_else(|err| panic!(\"update current {category} provider: {err}\"));\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .unwrap_or_else(|err| panic!(\"query current {category} provider: {err}\"))\n .unwrap_or_else(|| panic!(\"current {category} provider should exist\"));\n assert_eq!(saved.name, format!(\"Current {category} updated\"));\n\n let written = fs::read_to_string(omo_config_path(home, category))\n .unwrap_or_else(|err| panic!(\"read written {category} config: {err}\"));\n let written_json: Value = serde_json::from_str(&written)\n .unwrap_or_else(|err| panic!(\"parse written {category} config: {err}\"));\n\n assert_eq!(\n written_json[\"agents\"][\"writer\"][\"model\"],\n Value::String(format!(\"{category}-saved-model\")),\n \"{category} config should be written from the saved provider state\"\n );\n assert_eq!(\n written_json[\"theme\"],\n Value::String(format!(\"{category}-light\")),\n \"{category} top-level config should reflect updated otherFields\"\n );\n }\n });\n }\n\n #[test]\n #[serial]\n fn update_current_omo_variant_does_not_persist_database_when_file_write_fails() {\n with_test_home(|state, home| {\n let provider = opencode_omo_provider(\"omo-current\", \"omo\");\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .unwrap_or_else(|err| panic!(\"seed current omo provider: {err}\"));\n state\n .db\n .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, \"omo\")\n .unwrap_or_else(|err| panic!(\"set current omo provider: {err}\"));\n\n let config_dir = home.join(\".config\").join(\"opencode\");\n fs::create_dir_all(config_dir.parent().expect(\"config dir parent\"))\n .expect(\"create .config dir\");\n fs::write(&config_dir, \"not a directory\").expect(\"block opencode config dir\");\n\n let mut updated = provider.clone();\n updated.name = \"Current omo updated\".to_string();\n updated.settings_config[\"agents\"][\"writer\"][\"model\"] =\n Value::String(\"omo-saved-model\".to_string());\n\n ProviderService::update(state, AppType::OpenCode, None, updated)\n .expect_err(\"update should fail when current omo file write fails\");\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .unwrap_or_else(|err| panic!(\"query current omo provider: {err}\"))\n .unwrap_or_else(|| panic!(\"current omo provider should exist\"));\n\n assert_eq!(saved.name, provider.name);\n assert_eq!(\n saved.settings_config[\"agents\"][\"writer\"][\"model\"],\n provider.settings_config[\"agents\"][\"writer\"][\"model\"],\n \"database should remain unchanged when file write fails\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn update_current_omo_variant_rolls_back_file_when_plugin_sync_fails() {\n with_test_home(|state, home| {\n let provider = opencode_omo_provider(\"omo-current\", \"omo\");\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .unwrap_or_else(|err| panic!(\"seed current omo provider: {err}\"));\n state\n .db\n .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, \"omo\")\n .unwrap_or_else(|err| panic!(\"set current omo provider: {err}\"));\n\n let config_path = omo_config_path(home, \"omo\");\n fs::create_dir_all(config_path.parent().expect(\"omo config parent\"))\n .expect(\"create omo config dir\");\n let previous_content = serde_json::to_string_pretty(&json!({\n \"theme\": \"legacy-live-theme\",\n \"agents\": {\n \"writer\": {\n \"model\": \"legacy-live-model\"\n }\n },\n \"categories\": {\n \"default\": [\"writer\"]\n }\n }))\n .expect(\"serialize previous config\");\n fs::write(&config_path, &previous_content).expect(\"seed previous omo config\");\n\n let opencode_config_path = home.join(\".config\").join(\"opencode\").join(\"opencode.json\");\n fs::write(&opencode_config_path, \"{ invalid json\").expect(\"seed malformed opencode\");\n\n let mut updated = provider.clone();\n updated.name = \"Current omo updated\".to_string();\n updated.settings_config[\"agents\"][\"writer\"][\"model\"] =\n Value::String(\"omo-saved-model\".to_string());\n updated.settings_config[\"otherFields\"][\"theme\"] =\n Value::String(\"omo-light\".to_string());\n\n ProviderService::update(state, AppType::OpenCode, None, updated)\n .expect_err(\"update should fail when plugin sync fails\");\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .unwrap_or_else(|err| panic!(\"query current omo provider: {err}\"))\n .unwrap_or_else(|| panic!(\"current omo provider should exist\"));\n\n assert_eq!(saved.name, provider.name);\n assert_eq!(\n saved.settings_config[\"agents\"][\"writer\"][\"model\"],\n provider.settings_config[\"agents\"][\"writer\"][\"model\"],\n \"database should remain unchanged when plugin sync fails\"\n );\n\n let written =\n fs::read_to_string(&config_path).expect(\"read rolled back omo config content\");\n assert_eq!(\n written, previous_content,\n \"OMO config should roll back to its previous on-disk contents\"\n );\n });\n }\n}\n\nimpl ProviderService {\n fn normalize_provider_if_claude(app_type: &AppType, provider: &mut Provider) {\n if matches!(app_type, AppType::Claude) {\n let mut v = provider.settings_config.clone();\n if normalize_claude_models_in_value(&mut v) {\n provider.settings_config = v;\n }\n }\n }\n\n /// Check whether a provider exists in live config, tolerating parse errors\n /// only for providers that are explicitly marked as DB-only.\n fn check_live_config_exists(\n app_type: &AppType,\n provider_id: &str,\n live_config_managed: Option<bool>,\n ) -> Result<bool, AppError> {\n if live_config_managed == Some(false) {\n Ok(provider_exists_in_live_config(app_type, provider_id).unwrap_or(false))\n } else {\n provider_exists_in_live_config(app_type, provider_id)\n }\n }\n\n fn provider_live_config_managed(provider: &Provider) -> Option<bool> {\n provider\n .meta\n .as_ref()\n .and_then(|meta| meta.live_config_managed)\n }\n\n fn set_provider_live_config_managed(provider: &mut Provider, managed: bool) {\n provider\n .meta\n .get_or_insert_with(Default::default)\n .live_config_managed = Some(managed);\n }\n\n fn normalize_usage_script_credential_overrides(app_type: &AppType, provider: &mut Provider) {\n let current_credentials = provider.resolve_usage_credentials(app_type);\n\n let Some(usage_script) = provider\n .meta\n .as_mut()\n .and_then(|meta| meta.usage_script.as_mut())\n else {\n return;\n };\n\n if usage_script.template_type.as_deref() == Some(\"token_plan\") {\n return;\n }\n\n if usage_script.api_key.as_deref().is_some_and(|api_key| {\n Self::should_clear_usage_api_key_override(api_key, &current_credentials)\n }) {\n usage_script.api_key = None;\n }\n\n if usage_script.base_url.as_deref().is_some_and(|base_url| {\n Self::should_clear_usage_base_url_override(base_url, &current_credentials)\n }) {\n usage_script.base_url = None;\n }\n }\n\n fn should_clear_usage_api_key_override(\n script_api_key: &str,\n current_credentials: &(String, String),\n ) -> bool {\n let candidate = script_api_key.trim();\n if candidate.is_empty() {\n return true;\n }\n\n let matches_provider_key = |api_key: &str| {\n let api_key = api_key.trim();\n !api_key.is_empty() && api_key == candidate\n };\n\n matches_provider_key(&current_credentials.1)\n }\n\n fn should_clear_usage_base_url_override(\n script_base_url: &str,\n current_credentials: &(String, String),\n ) -> bool {\n let candidate = Self::normalize_usage_base_url_for_compare(script_base_url);\n if candidate.is_empty() {\n return true;\n }\n\n let matches_provider_base_url = |base_url: &str| {\n let base_url = Self::normalize_usage_base_url_for_compare(base_url);\n !base_url.is_empty() && base_url == candidate\n };\n\n matches_provider_base_url(&current_credentials.0)\n }\n\n fn normalize_usage_base_url_for_compare(base_url: &str) -> String {\n base_url.trim().trim_end_matches('/').to_string()\n }\n\n /// List all providers for an app type\n pub fn list(\n state: &AppState,\n app_type: AppType,\n ) -> Result<IndexMap<String, Provider>, AppError> {\n state.db.get_all_providers(app_type.as_str())\n }\n\n /// Get current provider ID\n ///\n /// 使用有效的当前供应商 ID(验证过存在性)。\n /// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。\n /// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。\n ///\n /// 对于累加模式应用(OpenCode, OpenClaw),不存在\"当前供应商\"概念,直接返回空字符串。\n pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {\n // Additive mode apps have no \"current\" provider concept\n if app_type.is_additive_mode() {\n return Ok(String::new());\n }\n crate::settings::get_effective_current_provider(&state.db, &app_type)\n .map(|opt| opt.unwrap_or_default())\n }\n\n /// Add a new provider\n pub fn add(\n state: &AppState,\n app_type: AppType,\n provider: Provider,\n add_to_live: bool,\n ) -> Result<bool, AppError> {\n let mut provider = provider;\n // Normalize Claude model keys\n Self::normalize_provider_if_claude(&app_type, &mut provider);\n Self::validate_provider_settings(&app_type, &provider)?;\n normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;\n Self::normalize_usage_script_credential_overrides(&app_type, &mut provider);\n if app_type.is_additive_mode() {\n Self::set_provider_live_config_managed(&mut provider, add_to_live);\n }\n\n // Save to database\n state.db.save_provider(app_type.as_str(), &provider)?;\n\n // Additive mode apps (OpenCode, OpenClaw): optionally write to live config.\n if app_type.is_additive_mode() {\n // OMO / OMO Slim providers use exclusive mode and write to dedicated config file.\n if matches!(app_type, AppType::OpenCode)\n && matches!(provider.category.as_deref(), Some(\"omo\") | Some(\"omo-slim\"))\n {\n // Do not auto-enable newly added OMO / OMO Slim providers.\n // Users must explicitly switch/apply an OMO provider to activate it.\n return Ok(true);\n }\n if !add_to_live {\n return Ok(true);\n }\n write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n return Ok(true);\n }\n\n // For other apps: Check if sync is needed (if this is current provider, or no current provider)\n let current = state.db.get_current_provider(app_type.as_str())?;\n if current.is_none() {\n // No current provider, set as current and sync\n state\n .db\n .set_current_provider(app_type.as_str(), &provider.id)?;\n write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n }\n\n Ok(true)\n }\n\n /// Update a provider\n pub fn update(\n state: &AppState,\n app_type: AppType,\n original_id: Option<&str>,\n provider: Provider,\n ) -> Result<bool, AppError> {\n let mut provider = provider;\n let original_id = original_id.unwrap_or(provider.id.as_str()).to_string();\n let provider_id_changed = original_id != provider.id;\n let existing_provider = state\n .db\n .get_provider_by_id(&original_id, app_type.as_str())?;\n // Normalize Claude model keys\n Self::normalize_provider_if_claude(&app_type, &mut provider);\n Self::validate_provider_settings(&app_type, &provider)?;\n normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;\n Self::normalize_usage_script_credential_overrides(&app_type, &mut provider);\n\n if provider_id_changed {\n if !app_type.is_additive_mode() {\n return Err(AppError::Message(\n \"Only additive-mode providers support changing provider key\".to_string(),\n ));\n }\n\n let Some(existing_provider) = existing_provider else {\n return Err(AppError::Message(format!(\n \"Original provider '{}' does not exist in app '{}'\",\n original_id,\n app_type.as_str()\n )));\n };\n\n // OMO / OMO Slim providers are activated via a dedicated current-state mechanism\n // (set_omo_provider_current) that is NOT captured by provider_exists_in_live_config,\n // which only checks opencode.json. A rename would orphan that current-state marker\n // and silently break subsequent OMO file syncs. Block it unconditionally.\n if matches!(app_type, AppType::OpenCode)\n && matches!(\n existing_provider.category.as_deref(),\n Some(\"omo\") | Some(\"omo-slim\")\n )\n {\n return Err(AppError::Message(\n \"Provider key cannot be changed for OMO/OMO Slim providers\".to_string(),\n ));\n }\n\n let original_in_live = Self::check_live_config_exists(\n &app_type,\n &original_id,\n Self::provider_live_config_managed(&existing_provider),\n )?;\n if original_in_live {\n return Err(AppError::Message(\n \"Provider key cannot be changed after the provider has been added to the app config\"\n .to_string(),\n ));\n }\n\n let next_id_in_live = Self::check_live_config_exists(\n &app_type,\n &provider.id,\n Self::provider_live_config_managed(&existing_provider),\n )?;\n if state\n .db\n .get_provider_by_id(&provider.id, app_type.as_str())?\n .is_some()\n || next_id_in_live\n {\n return Err(AppError::Message(format!(\n \"Provider '{}' already exists in app '{}'\",\n provider.id,\n app_type.as_str()\n )));\n }\n\n Self::set_provider_live_config_managed(&mut provider, false);\n state.db.save_provider(app_type.as_str(), &provider)?;\n state.db.delete_provider(app_type.as_str(), &original_id)?;\n\n if crate::settings::get_current_provider(&app_type).as_deref() == Some(&original_id) {\n crate::settings::set_current_provider(&app_type, Some(provider.id.as_str()))?;\n }\n\n return Ok(true);\n }\n\n // Additive mode apps (OpenCode, OpenClaw): only sync to live when the provider\n // already exists in live config. Editing a DB-only provider must not auto-add it.\n if app_type.is_additive_mode() {\n let omo_variant = if matches!(app_type, AppType::OpenCode) {\n match provider.category.as_deref() {\n Some(\"omo\") => Some(&crate::services::omo::STANDARD),\n Some(\"omo-slim\") => Some(&crate::services::omo::SLIM),\n _ => None,\n }\n } else {\n None\n };\n if let Some(variant) = omo_variant {\n let is_current = state.db.is_omo_provider_current(\n app_type.as_str(),\n &provider.id,\n variant.category,\n )?;\n if is_current {\n crate::services::OmoService::write_provider_config_to_file(&provider, variant)?;\n }\n if let Err(err) = state.db.save_provider(app_type.as_str(), &provider) {\n if is_current {\n if let Err(rollback_err) =\n crate::services::OmoService::write_config_to_file(state, variant)\n {\n log::warn!(\n \"Failed to roll back {} config after DB save error: {}\",\n variant.label,\n rollback_err\n );\n }\n }\n return Err(err);\n }\n return Ok(true);\n }\n let live_config_managed = Self::check_live_config_exists(\n &app_type,\n &provider.id,\n Self::provider_live_config_managed(&provider).or_else(|| {\n existing_provider\n .as_ref()\n .and_then(Self::provider_live_config_managed)\n }),\n )?;\n Self::set_provider_live_config_managed(&mut provider, live_config_managed);\n\n // Save to database after live-config presence is resolved so parse errors\n // do not report failure after already mutating DB state.\n state.db.save_provider(app_type.as_str(), &provider)?;\n\n if !live_config_managed {\n return Ok(true);\n }\n write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n return Ok(true);\n }\n\n // Save to database\n state.db.save_provider(app_type.as_str(), &provider)?;\n\n // For other apps: Check if this is current provider (use effective current, not just DB)\n let effective_current =\n crate::settings::get_effective_current_provider(&state.db, &app_type)?;\n let is_current = effective_current.as_deref() == Some(provider.id.as_str());\n\n if is_current {\n // 如果 Claude 代理接管处于激活状态,并且代理服务正在运行:\n // - 不直接走普通 Live 写入逻辑\n // - 改为更新 Live 备份,并在 Claude 下同步代理安全的 Live 配置\n let has_live_backup =\n futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))\n .ok()\n .flatten()\n .is_some();\n let live_taken_over = state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&app_type);\n // Backup or live placeholders mean the live file is currently owned\n // by proxy takeover, including the short activation window before\n // proxy_config.enabled is committed.\n let should_sync_via_proxy = has_live_backup || live_taken_over;\n\n if should_sync_via_proxy {\n if matches!(app_type, AppType::ClaudeDesktop) {\n write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n } else {\n futures::executor::block_on(\n state\n .proxy_service\n .update_live_backup_from_provider(app_type.as_str(), &provider),\n )\n .map_err(|e| AppError::Message(format!(\"更新 Live 备份失败: {e}\")))?;\n }\n\n if futures::executor::block_on(state.proxy_service.is_running()) {\n if matches!(app_type, AppType::Claude) {\n futures::executor::block_on(\n state\n .proxy_service\n .sync_claude_live_from_provider_while_proxy_active(&provider),\n )\n .map_err(|e| {\n AppError::Message(format!(\"同步 Claude Live 配置失败: {e}\"))\n })?;\n } else if live_taken_over && matches!(app_type, AppType::Codex) {\n // Codex model mappings are projected into a generated\n // model_catalog_json file. Refresh takeover-owned Live\n // immediately so adding/removing mappings cannot leave\n // the previous catalog pointer and capabilities active.\n futures::executor::block_on(\n state\n .proxy_service\n .sync_codex_live_from_provider_while_proxy_active(&provider),\n )\n .map_err(|e| AppError::Message(format!(\"同步 Codex Live 配置失败: {e}\")))?;\n }\n }\n } else {\n write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n // 重写 live 后只重投影本应用的 MCP:全量 sync_all_enabled 会把\n // 无关应用的 live 损坏(如 ~/.claude.json 坏 JSON)牵连进保存\n // 流程。走到这里 DB 与 live 都已按新配置落盘,保存事实上已\n // 成功;投影失败降级为警告,避免制造\"保存失败\"假象(MCP\n // 投影可自愈:下次切换 / 任一 MCP 启停都会重新投影)。\n if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) {\n log::warn!(\n \"保存供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}\"\n );\n }\n }\n }\n\n Ok(true)\n }\n\n /// Delete a provider\n ///\n /// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。\n /// 对于累加模式应用(OpenCode, OpenClaw),可以随时删除任意供应商,同时从 live 配置中移除。\n pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {\n // Additive mode apps - no current provider concept\n if app_type.is_additive_mode() {\n // Single DB read shared across all additive-mode sub-paths below.\n let existing = state.db.get_provider_by_id(id, app_type.as_str())?;\n\n if matches!(app_type, AppType::OpenCode) {\n let provider_category = existing.as_ref().and_then(|p| p.category.clone());\n let omo_variant = match provider_category.as_deref() {\n Some(\"omo\") => Some(&crate::services::omo::STANDARD),\n Some(\"omo-slim\") => Some(&crate::services::omo::SLIM),\n _ => None,\n };\n if let Some(variant) = omo_variant {\n let was_current = state.db.is_omo_provider_current(\n app_type.as_str(),\n id,\n variant.category,\n )?;\n state.db.delete_provider(app_type.as_str(), id)?;\n if was_current {\n crate::services::OmoService::delete_config_file(variant)?;\n }\n return Ok(());\n }\n }\n\n // Non-OMO path for both OpenCode and OpenClaw:\n // remove from live first (atomicity), then DB.\n //\n // Use check_live_config_exists rather than trusting the flag alone: the flag\n // can be stale (Some(false) for a provider that was written to live before the\n // live_config_managed flip was introduced). check_live_config_exists reads the\n // actual file when the flag is Some(false), so it handles historical data correctly.\n let live_managed = existing\n .as_ref()\n .and_then(Self::provider_live_config_managed);\n if Self::check_live_config_exists(&app_type, id, live_managed)? {\n match app_type {\n AppType::OpenCode => remove_opencode_provider_from_live(id)?,\n AppType::OpenClaw => remove_openclaw_provider_from_live(id)?,\n AppType::Hermes => remove_hermes_provider_from_live(id)?,\n _ => {}\n }\n }\n state.db.delete_provider(app_type.as_str(), id)?;\n return Ok(());\n }\n\n // For other apps: Check both local settings and database\n let local_current = crate::settings::get_current_provider(&app_type);\n let db_current = state.db.get_current_provider(app_type.as_str())?;\n\n if local_current.as_deref() == Some(id) || db_current.as_deref() == Some(id) {\n return Err(AppError::Message(\n \"无法删除当前正在使用的供应商\".to_string(),\n ));\n }\n\n state.db.delete_provider(app_type.as_str(), id)\n }\n\n /// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw)\n ///\n /// Does NOT delete from database - provider remains in the list.\n /// This is used when user wants to \"remove\" a provider from active config\n /// but keep it available for future use.\n pub fn remove_from_live_config(\n state: &AppState,\n app_type: AppType,\n id: &str,\n ) -> Result<(), AppError> {\n match app_type {\n AppType::OpenCode => {\n let provider_category = state\n .db\n .get_provider_by_id(id, app_type.as_str())?\n .and_then(|p| p.category);\n\n let omo_variant = match provider_category.as_deref() {\n Some(\"omo\") => Some(&crate::services::omo::STANDARD),\n Some(\"omo-slim\") => Some(&crate::services::omo::SLIM),\n _ => None,\n };\n if let Some(variant) = omo_variant {\n state\n .db\n .clear_omo_provider_current(app_type.as_str(), id, variant.category)?;\n let still_has_current = state\n .db\n .get_current_omo_provider(\"opencode\", variant.category)?\n .is_some();\n if still_has_current {\n crate::services::OmoService::write_config_to_file(state, variant)?;\n } else {\n crate::services::OmoService::delete_config_file(variant)?;\n }\n } else {\n remove_opencode_provider_from_live(id)?;\n }\n }\n AppType::OpenClaw => {\n remove_openclaw_provider_from_live(id)?;\n }\n AppType::Hermes => {\n remove_hermes_provider_from_live(id)?;\n }\n _ => {\n return Err(AppError::Message(format!(\n \"App {} does not support remove from live config\",\n app_type.as_str()\n )));\n }\n }\n\n if let Some(mut provider) = state.db.get_provider_by_id(id, app_type.as_str())? {\n Self::set_provider_live_config_managed(&mut provider, false);\n state.db.save_provider(app_type.as_str(), &provider)?;\n }\n\n Ok(())\n }\n\n /// Switch to a provider\n ///\n /// Switch flow:\n /// 1. Validate target provider exists\n /// 2. Check if proxy takeover mode is active AND proxy server is running\n /// 3. If takeover mode active: hot-switch proxy target and refresh proxy-safe Live labels\n /// 4. If normal mode:\n /// a. **Backfill mechanism**: Backfill current live config to current provider\n /// b. Update local settings current_provider_xxx (device-level)\n /// c. Update database is_current (as default for new devices)\n /// d. Write target provider config to live files\n /// e. Sync MCP configuration\n pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<SwitchResult, AppError> {\n // Check if provider exists\n let providers = state.db.get_all_providers(app_type.as_str())?;\n let _provider = providers\n .get(id)\n .ok_or_else(|| AppError::Message(format!(\"供应商 {id} 不存在\")))?;\n\n // OMO providers are switched through their own exclusive path.\n if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some(\"omo\") {\n return Self::switch_normal(state, app_type, id, &providers);\n }\n\n // OMO Slim providers are switched through their own exclusive path.\n if matches!(app_type, AppType::OpenCode)\n && _provider.category.as_deref() == Some(\"omo-slim\")\n {\n return Self::switch_normal(state, app_type, id, &providers);\n }\n\n if matches!(app_type, AppType::ClaudeDesktop) {\n return Self::switch_normal(state, app_type, id, &providers);\n }\n\n // Provider switches and takeover toggles both mutate live config and the\n // restore backup. Serialize them per app, then decide from the locked\n // current state so a just-started takeover cannot be overwritten by a\n // normal live write.\n let _switch_guard =\n if matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) {\n Some(futures::executor::block_on(\n state.proxy_service.lock_switch_for_app(app_type.as_str()),\n ))\n } else {\n None\n };\n\n // Backup or live placeholders mean the live file is owned by proxy\n // takeover, even if the proxy server is temporarily stopped or is in the\n // activation window before enabled=true is committed.\n let is_app_taken_over =\n futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))\n .ok()\n .flatten()\n .is_some();\n let live_taken_over = state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&app_type);\n\n let should_hot_switch = is_app_taken_over || live_taken_over;\n\n // Block switching to official providers when proxy takeover is active.\n // Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans.\n if should_hot_switch\n && _provider.category.as_deref() == Some(\"official\")\n && !official_provider_supports_proxy_takeover(&app_type, _provider)\n {\n return Err(AppError::localized(\n \"switch.official_blocked_by_proxy\",\n \"代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。\",\n \"Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.\",\n ));\n }\n\n if should_hot_switch {\n // Proxy takeover mode: hot-switch without restoring upstream Live config.\n // The proxy layer may still refresh proxy-safe Live fields so client labels\n // follow the selected provider while endpoints remain local.\n log::info!(\n \"代理接管模式:热切换 {} 的目标供应商为 {}\",\n app_type.as_str(),\n id\n );\n\n futures::executor::block_on(\n state\n .proxy_service\n .hot_switch_provider_inner(app_type.as_str(), id),\n )\n .map_err(|e| AppError::Message(format!(\"热切换失败: {e}\")))?;\n\n // The proxy server will route requests to the new provider via is_current.\n // MCP sync is intentionally skipped while Live config is owned by takeover.\n return Ok(SwitchResult::default());\n }\n\n // Normal mode: full switch with Live config write\n Self::switch_normal(state, app_type, id, &providers)\n }\n\n /// Normal switch flow (non-proxy mode)\n fn switch_normal(\n state: &AppState,\n app_type: AppType,\n id: &str,\n providers: &indexmap::IndexMap<String, Provider>,\n ) -> Result<SwitchResult, AppError> {\n let provider = providers\n .get(id)\n .ok_or_else(|| AppError::Message(format!(\"供应商 {id} 不存在\")))?;\n\n // OMO ↔ OMO Slim are mutually exclusive; activating one removes the other's config file.\n if matches!(app_type, AppType::OpenCode) {\n let omo_pair = match provider.category.as_deref() {\n Some(\"omo\") => Some((&crate::services::omo::STANDARD, &crate::services::omo::SLIM)),\n Some(\"omo-slim\") => {\n Some((&crate::services::omo::SLIM, &crate::services::omo::STANDARD))\n }\n _ => None,\n };\n if let Some((enable, disable)) = omo_pair {\n state\n .db\n .set_omo_provider_current(app_type.as_str(), id, enable.category)?;\n crate::services::OmoService::write_config_to_file(state, enable)?;\n let _ = crate::services::OmoService::delete_config_file(disable);\n return Ok(SwitchResult::default());\n }\n }\n\n let mut result = SwitchResult::default();\n\n // Backfill: Backfill current live config to current provider\n // Use effective current provider (validated existence) to ensure backfill targets valid provider\n let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;\n\n if let Some(current_id) = current_id {\n if current_id != id {\n // Additive mode apps - all providers coexist in the same file,\n // no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)\n if !app_type.is_additive_mode() {\n // Only backfill when switching to a different provider\n if let Ok(live_config) = read_live_settings(app_type.clone()) {\n if let Some(mut current_provider) = providers.get(&current_id).cloned() {\n // 切走前先把 live 里的可共享改动(含用户直接在应用内\n // 装插件/加 hook/改偏好)同步进通用配置片段,再做剥离回填。\n // 详见 sync_common_config_snippet_from_live 的文档。\n Self::sync_common_config_snippet_from_live(\n state,\n &app_type,\n &current_provider,\n &live_config,\n &mut result,\n );\n\n current_provider.settings_config =\n strip_common_config_from_live_settings(\n state.db.as_ref(),\n &app_type,\n &current_provider,\n live_config,\n );\n if let Err(e) =\n state.db.save_provider(app_type.as_str(), &current_provider)\n {\n log::warn!(\"Backfill failed: {e}\");\n result\n .warnings\n .push(format!(\"backfill_failed:{current_id}\"));\n }\n }\n }\n }\n }\n }\n\n // Additive mode apps skip setting is_current (no such concept)\n if !app_type.is_additive_mode() {\n // Update local settings (device-level, takes priority)\n crate::settings::set_current_provider(&app_type, Some(id))?;\n\n // Update database is_current (as default for new devices)\n state.db.set_current_provider(app_type.as_str(), id)?;\n }\n\n // Sync to live (write_gemini_live handles security flag internally for Gemini)\n write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;\n\n // Hermes is additive, so \"switching\" doesn't overwrite a live config file\n // — we instead update the top-level `model:` section to point at this\n // provider's first declared model. Without this, clicking \"switch\" would\n // only shuffle entries in custom_providers[] while Hermes keeps using\n // whatever `model.provider` was set before.\n if matches!(app_type, AppType::Hermes) {\n if let Err(e) =\n crate::hermes_config::apply_switch_defaults(&provider.id, &provider.settings_config)\n {\n log::warn!(\n \"Failed to update Hermes model defaults after switching to '{}': {e}\",\n provider.id\n );\n result\n .warnings\n .push(format!(\"hermes_model_defaults_failed:{}\", provider.id));\n }\n }\n\n // For additive-mode providers that were DB-only (live_config_managed == Some(false)),\n // flip the flag to true now that the provider has been successfully written to the live\n // file. This ensures sync_all_providers_to_live() will include it on future syncs.\n //\n // If persisting the marker fails, roll back the just-written live config so we don't leave\n // the provider in a silent inconsistent state (present in live, but still marked DB-only).\n if app_type.is_additive_mode() && Self::provider_live_config_managed(provider) != Some(true)\n {\n let mut updated = provider.clone();\n Self::set_provider_live_config_managed(&mut updated, true);\n if let Err(e) = state.db.save_provider(app_type.as_str(), &updated) {\n let rollback_result = match app_type {\n AppType::OpenCode => remove_opencode_provider_from_live(&provider.id),\n AppType::OpenClaw => remove_openclaw_provider_from_live(&provider.id),\n AppType::Hermes => remove_hermes_provider_from_live(&provider.id),\n _ => Ok(()),\n };\n\n match rollback_result {\n Ok(()) => {\n return Err(AppError::Message(format!(\n \"Failed to persist live_config_managed for '{}' after writing live config; live changes were rolled back: {e}\",\n provider.id\n )));\n }\n Err(rollback_err) => {\n return Err(AppError::Message(format!(\n \"Failed to persist live_config_managed for '{}' after writing live config: {e}; additionally failed to roll back live config: {rollback_err}\",\n provider.id\n )));\n }\n }\n }\n }\n\n // 切换重写了目标应用的 live,只重投影该应用的 MCP(Codex 的\n // [mcp_servers] 与 live 同文件,整体替换后必须补回;其余应用的\n // MCP 文件独立于 live,投影是幂等维护)。不用全量 sync_all_enabled:\n // 无关应用的 live 损坏(如 ~/.claude.json 坏 JSON)不该阻断切换。\n // 走到这里 DB is_current 与 live 都已落盘,切换事实上已成功;\n // 投影失败上抛会让前端报\"切换失败\"制造分裂假象,故降级为警告\n // (MCP 投影可自愈:下次切换 / 任一 MCP 启停都会重新投影)。\n if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) {\n log::warn!(\"切换供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}\");\n }\n\n Ok(result)\n }\n\n /// Sync current provider to live configuration (re-export)\n pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {\n sync_current_to_live(state)\n }\n\n pub fn sync_current_provider_for_app(\n state: &AppState,\n app_type: AppType,\n ) -> Result<(), AppError> {\n if app_type.is_additive_mode() {\n return sync_current_provider_for_app_to_live(state, &app_type);\n }\n\n let current_id =\n match crate::settings::get_effective_current_provider(&state.db, &app_type)? {\n Some(id) => id,\n None => return Ok(()),\n };\n\n let providers = state.db.get_all_providers(app_type.as_str())?;\n let Some(provider) = providers.get(&current_id) else {\n return Ok(());\n };\n\n let has_live_backup =\n futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))\n .ok()\n .flatten()\n .is_some();\n\n let live_taken_over = state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&app_type);\n\n // See the save path above: backup/placeholders are the ownership signal\n // here, not just proxy_config.enabled.\n if has_live_backup || live_taken_over {\n if matches!(app_type, AppType::ClaudeDesktop) {\n write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;\n return Ok(());\n }\n\n futures::executor::block_on(\n state\n .proxy_service\n .update_live_backup_from_provider(app_type.as_str(), provider),\n )\n .map_err(|e| AppError::Message(format!(\"更新 Live 备份失败: {e}\")))?;\n return Ok(());\n }\n\n sync_current_provider_for_app_to_live(state, &app_type)\n }\n\n pub fn migrate_legacy_common_config_usage(\n state: &AppState,\n app_type: AppType,\n legacy_snippet: &str,\n ) -> Result<(), AppError> {\n if app_type.is_additive_mode() || legacy_snippet.trim().is_empty() {\n return Ok(());\n }\n\n let providers = state.db.get_all_providers(app_type.as_str())?;\n\n for provider in providers.values() {\n if provider\n .meta\n .as_ref()\n .and_then(|meta| meta.common_config_enabled)\n .is_some()\n {\n continue;\n }\n\n if !live::provider_uses_common_config(&app_type, provider, Some(legacy_snippet)) {\n continue;\n }\n\n let mut updated_provider = provider.clone();\n updated_provider\n .meta\n .get_or_insert_with(Default::default)\n .common_config_enabled = Some(true);\n\n match live::remove_common_config_from_settings(\n &app_type,\n &updated_provider.settings_config,\n legacy_snippet,\n ) {\n Ok(settings) => updated_provider.settings_config = settings,\n Err(err) => {\n log::warn!(\n \"Failed to normalize legacy common config for {} provider '{}': {err}\",\n app_type.as_str(),\n updated_provider.id\n );\n }\n }\n\n state\n .db\n .save_provider(app_type.as_str(), &updated_provider)?;\n }\n\n Ok(())\n }\n\n pub fn migrate_legacy_common_config_usage_if_needed(\n state: &AppState,\n app_type: AppType,\n ) -> Result<(), AppError> {\n if app_type.is_additive_mode() {\n return Ok(());\n }\n\n let Some(snippet) = state.db.get_config_snippet(app_type.as_str())? else {\n return Ok(());\n };\n\n if snippet.trim().is_empty() {\n return Ok(());\n }\n\n Self::migrate_legacy_common_config_usage(state, app_type, &snippet)\n }\n\n /// 切走某供应商前,把它 live 配置里的可共享部分重新提取并**整体替换**到\n /// 通用配置片段,使在 live 应用里直接做的改动不会因切换而丢失。\n ///\n /// 采用\"整体重提取 + 替换\"而非\"只合并新增\",是为了同时覆盖三种情况:\n /// - **新增**:用户直接在应用里装了插件、加了 hook、改了 env/主题/权限等共享\n /// 偏好,被捕获进通用配置,切到别的供应商也带得过去;\n /// - **删除**:被删掉的键不在新提取结果里,于是从片段里消失、下次切换不会被\n /// 重新注入——否则会出现\"插件怎么删也删不掉\"的反直觉 bug;\n /// - **密钥安全**:提取器已剥掉 auth / model / endpoint,密钥永不进共享片段。\n ///\n /// 之所以\"整体替换\"是安全的:每次写 live 都会把当前片段合并进去,所以切走时\n /// 读到的 live 一定是\"片段 + 本地改动\"的超集,重提取只会丢掉用户真正删掉的键,\n /// 不会误删其它供应商共享的内容。\n ///\n /// **作用域**:Claude + Codex。Codex 提取器(`extract_codex_common_config`)\n /// 已剥离全部供应商专属与 cc-switch 注入内容:`model` / `model_provider` /\n /// 顶层 `base_url` / 整张 `model_providers` 表(含端点与统一会话桶)、\n /// `mcp_servers`(SSOT 在 DB 表)、顶层 `experimental_bearer_token`\n /// fallback、`model_catalog_json`、`web_search = \"disabled\"` 哨兵——密钥与\n /// 注入产物不会进共享片段。Gemini 暂未纳入,如需支持应单独验证后再加。\n ///\n /// 仅对**显式勾选\"写入通用配置\"**(`meta.common_config_enabled == Some(true)`)的\n /// 供应商生效;用户**显式清空**过片段(`_cleared`)时跳过,避免把用户主动清掉的\n /// 配置又塞回来。所有失败均为非致命,只记 warning,绝不阻断切换。\n fn sync_common_config_snippet_from_live(\n state: &AppState,\n app_type: &AppType,\n provider: &Provider,\n live_config: &Value,\n result: &mut SwitchResult,\n ) {\n // 作用域限定 Claude + Codex(见函数文档)。\n if !matches!(app_type, AppType::Claude | AppType::Codex) {\n return;\n }\n\n let opted_in = provider\n .meta\n .as_ref()\n .and_then(|meta| meta.common_config_enabled)\n == Some(true);\n if !opted_in {\n return;\n }\n\n match state.db.is_config_snippet_cleared(app_type.as_str()) {\n Ok(true) => return, // 用户显式清空过通用配置,尊重其选择,不再自动塞回\n Ok(false) => {}\n Err(err) => {\n log::warn!(\n \"Failed to read common config cleared flag for {}: {err}\",\n app_type.as_str()\n );\n return;\n }\n }\n\n let new_snippet = match Self::extract_common_config_snippet_from_settings(\n app_type.clone(),\n live_config,\n ) {\n Ok(snippet) => snippet,\n Err(err) => {\n log::warn!(\n \"Failed to extract common config from live for {} provider '{}': {err}\",\n app_type.as_str(),\n provider.id\n );\n return;\n }\n };\n\n // 未变化则跳过,避免无谓写库(不切 live 配置时这是常态路径)。\n let current = state\n .db\n .get_config_snippet(app_type.as_str())\n .ok()\n .flatten();\n if current.as_deref() == Some(new_snippet.as_str()) {\n return;\n }\n\n if let Err(err) = state\n .db\n .set_config_snippet(app_type.as_str(), Some(new_snippet))\n {\n log::warn!(\n \"Failed to persist synced common config for {} provider '{}': {err}\",\n app_type.as_str(),\n provider.id\n );\n result\n .warnings\n .push(format!(\"common_config_sync_failed:{}\", provider.id));\n }\n }\n\n /// Extract common config snippet from current provider\n ///\n /// Extracts the current provider's configuration and removes provider-specific fields\n /// (API keys, model settings, endpoints) to create a reusable common config snippet.\n pub fn extract_common_config_snippet(\n state: &AppState,\n app_type: AppType,\n ) -> Result<String, AppError> {\n // Get current provider\n let current_id = Self::current(state, app_type.clone())?;\n if current_id.is_empty() {\n return Err(AppError::Message(\"No current provider\".to_string()));\n }\n\n let providers = state.db.get_all_providers(app_type.as_str())?;\n let provider = providers\n .get(&current_id)\n .ok_or_else(|| AppError::Message(format!(\"Provider {current_id} not found\")))?;\n\n match app_type {\n AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),\n AppType::ClaudeDesktop => Ok(String::new()),\n AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),\n AppType::Grok => Ok(String::new()),\n AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),\n AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),\n AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),\n AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets\n }\n }\n\n /// Extract common config snippet from a config value (e.g. editor content).\n pub fn extract_common_config_snippet_from_settings(\n app_type: AppType,\n settings_config: &Value,\n ) -> Result<String, AppError> {\n match app_type {\n AppType::Claude => Self::extract_claude_common_config(settings_config),\n AppType::ClaudeDesktop => Ok(String::new()),\n AppType::Codex => Self::extract_codex_common_config(settings_config),\n AppType::Grok => Ok(String::new()),\n AppType::Gemini => Self::extract_gemini_common_config(settings_config),\n AppType::OpenCode => Self::extract_opencode_common_config(settings_config),\n AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),\n AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets\n }\n }\n\n /// 判断一个 env / 顶层配置键名是否为凭据/机密:凡命中一律不得写入共享的\n /// 通用配置片段。**故意从严**——多剥一个非机密键只是它不被共享(可恢复的小\n /// 不便),漏剥一个凭据则会把密钥注入到每个供应商(不可恢复的泄漏)。因此用\n /// 模式匹配覆盖整类,而非枚举具体名字(枚举永远会漏掉下一个 `*_API_KEY`)。\n ///\n /// 覆盖:Anthropic / OpenRouter / Google / OpenAI / Gemini 等 `*_API_KEY`\n /// (Claude provider 的凭据见 `Provider::resolve_usage_credentials`,确实支持\n /// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` /\n /// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password /\n /// 私钥命名。\n fn is_sensitive_config_key(name: &str) -> bool {\n let upper = name.to_ascii_uppercase();\n\n // 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但**不**误伤复数 `_TOKENS`\n // (CLAUDE_CODE_MAX_OUTPUT_TOKENS / MAX_THINKING_TOKENS 是正常可共享配置)。\n const SENSITIVE_SUFFIXES: &[&str] = &[\n \"_API_KEY\",\n \"_APIKEY\",\n \"_AUTH_TOKEN\",\n \"_TOKEN\",\n \"_ACCESS_KEY\",\n \"_ACCESS_KEY_ID\",\n \"_KEY_ID\",\n \"_PRIVATE_KEY\",\n ];\n const SENSITIVE_EXACT: &[&str] = &[\n \"APIKEY\",\n \"API_KEY\",\n \"TOKEN\",\n \"SECRET\",\n \"PASSWORD\",\n \"CREDENTIALS\",\n ];\n // contains:覆盖 AWS_SECRET_ACCESS_KEY / *_CLIENT_SECRET /\n // GOOGLE_APPLICATION_CREDENTIALS / AWS_BEARER_TOKEN_BEDROCK 等变体。\n const SENSITIVE_CONTAINS: &[&str] = &[\n \"SECRET\",\n \"PASSWORD\",\n \"PASSWD\",\n \"CREDENTIAL\",\n \"PRIVATE_KEY\",\n \"BEARER_TOKEN\",\n ];\n\n SENSITIVE_EXACT.contains(&upper.as_str())\n || SENSITIVE_SUFFIXES.iter().any(|s| upper.ends_with(s))\n || SENSITIVE_CONTAINS.iter().any(|c| upper.contains(c))\n }\n\n /// Extract common config for Claude (JSON format)\n fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {\n let mut config = settings.clone();\n\n // 供应商专属的**非机密**字段(模型 + 端点),不应共享。凭据/机密不在此列举,\n // 改由 `is_sensitive_config_key`(模式匹配)统一剥离,新供应商的 `*_API_KEY`\n // 等无需再手工补名单即可被覆盖。\n const ENV_PROVIDER_SPECIFIC_EXCLUDES: &[&str] = &[\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n // Fable 是 v3.16.3 新增的第四档模型映射,与 haiku/sonnet/opus 同属供应商专属,\n // 不得进入通用配置片段,否则会污染其它供应商(issue #4272)。\n \"ANTHROPIC_DEFAULT_FABLE_MODEL\",\n \"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME\",\n \"CLAUDE_CODE_SUBAGENT_MODEL\",\n // Context limits follow the actual upstream model. Sharing these\n // across providers can cap GPT/Kimi to the wrong window and make\n // Claude Code compact too early or miss the upstream limit.\n \"CLAUDE_CODE_MAX_CONTEXT_TOKENS\",\n \"CLAUDE_CODE_AUTO_COMPACT_WINDOW\",\n \"ANTHROPIC_BASE_URL\",\n ];\n\n const TOP_LEVEL_EXCLUDES: &[&str] = &[\n \"apiBaseUrl\",\n // Legacy model fields\n \"primaryModel\",\n \"smallFastModel\",\n ];\n\n // Remove env fields: provider-specific (models/endpoint) + 任何凭据键。\n if let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n let sensitive: Vec<String> = env\n .keys()\n .filter(|k| Self::is_sensitive_config_key(k))\n .cloned()\n .collect();\n for key in ENV_PROVIDER_SPECIFIC_EXCLUDES {\n env.remove(*key);\n }\n for key in &sensitive {\n env.remove(key);\n }\n // If env is empty after removal, remove the env object itself\n if env.is_empty() {\n config.as_object_mut().map(|obj| obj.remove(\"env\"));\n }\n }\n\n // Remove top-level fields: legacy model fields + 任何凭据键\n // (例如非标准的顶层 apiKey / api_key / *_TOKEN)。\n if let Some(obj) = config.as_object_mut() {\n let sensitive: Vec<String> = obj\n .keys()\n .filter(|k| Self::is_sensitive_config_key(k))\n .cloned()\n .collect();\n for key in TOP_LEVEL_EXCLUDES {\n obj.remove(*key);\n }\n for key in &sensitive {\n obj.remove(key);\n }\n }\n\n // Check if result is empty\n if config.as_object().is_none_or(|obj| obj.is_empty()) {\n return Ok(\"{}\".to_string());\n }\n\n serde_json::to_string_pretty(&config)\n .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n }\n\n /// Extract common config for Codex (TOML format)\n fn extract_codex_common_config(settings: &Value) -> Result<String, AppError> {\n // Codex config is stored as { \"auth\": {...}, \"config\": \"toml string\" }\n let config_toml = settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n\n if config_toml.is_empty() {\n return Ok(String::new());\n }\n\n let mut doc = config_toml\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| AppError::Message(format!(\"TOML parse error: {e}\")))?;\n\n // Remove provider-specific fields.\n let root = doc.as_table_mut();\n root.remove(\"model\");\n root.remove(\"model_provider\");\n // Legacy/alt formats might use a top-level base_url.\n root.remove(\"base_url\");\n // wire_api 与 base_url 同属供应商路由语义:无 model_provider 时\n // update_codex_toml_field / 前端 setCodexWireApi 都会把它落在顶层,\n // 进了片段会改写其它供应商的协议选择(chat vs responses)。\n root.remove(\"wire_api\");\n\n // Remove entire model_providers table (provider-specific configuration)\n root.remove(\"model_providers\");\n\n // MCP 服务器归 DB mcp_servers 表所有:进了共享片段会绕过按应用的\n // 启用状态被合并进所有勾选通用配置的供应商,且在通用配置编辑框里\n // 显示为一份\"重复\"的 MCP 配置。\n root.remove(\"mcp_servers\");\n // 历史错误格式 [mcp.servers] 一并剥离(与 strip_codex_mcp_servers_from_settings\n // 一致):sync_all_enabled 只管理 [mcp_servers.*],legacy 形态一旦进了\n // 片段就会被合并进所有供应商,且没有任何同步路径能清掉这个孤儿。\n if let Some(mcp_tbl) = root\n .get_mut(\"mcp\")\n .and_then(|item| item.as_table_like_mut())\n {\n mcp_tbl.remove(\"servers\");\n if mcp_tbl.is_empty() {\n root.remove(\"mcp\");\n }\n }\n\n // cc-switch 写 live 时注入的产物一律不进共享片段:\n // - experimental_bearer_token 正常写在 [model_providers.<id>] 内(上面\n // 整表已剥),但无活跃路由 / 内建保留 id / 路由表缺失三种 fallback\n // 会落在顶层——不剥等于把 API 密钥写进共享片段。\n root.remove(\"experimental_bearer_token\");\n // - model_catalog_json 指向按供应商生成的 catalog 投影文件(DB 为 SSOT)。\n root.remove(\"model_catalog_json\");\n // - web_search 只剥 cc-switch 注入的 \"disabled\" 哨兵;用户手设的其它值\n // 属于可共享偏好,保留。\n if root\n .get(crate::codex_config::CODEX_WEB_SEARCH_FIELD)\n .and_then(|item| item.as_str())\n == Some(crate::codex_config::CODEX_WEB_SEARCH_DISABLED)\n {\n root.remove(crate::codex_config::CODEX_WEB_SEARCH_FIELD);\n }\n\n // Clean up multiple empty lines (keep at most one blank line).\n let mut cleaned = String::new();\n let mut blank_run = 0usize;\n for line in doc.to_string().lines() {\n if line.trim().is_empty() {\n blank_run += 1;\n if blank_run <= 1 {\n cleaned.push('\\n');\n }\n continue;\n }\n blank_run = 0;\n cleaned.push_str(line);\n cleaned.push('\\n');\n }\n\n Ok(cleaned.trim().to_string())\n }\n\n /// Extract common config for Gemini (JSON format)\n ///\n /// Extracts `.env` values while excluding provider-specific credentials:\n /// - GOOGLE_GEMINI_BASE_URL\n /// - GEMINI_API_KEY\n fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {\n let env = settings.get(\"env\").and_then(|v| v.as_object());\n\n let mut snippet = serde_json::Map::new();\n if let Some(env) = env {\n for (key, value) in env {\n if key == \"GOOGLE_GEMINI_BASE_URL\" || key == \"GEMINI_API_KEY\" {\n continue;\n }\n let Value::String(v) = value else {\n continue;\n };\n let trimmed = v.trim();\n if !trimmed.is_empty() {\n snippet.insert(key.to_string(), Value::String(trimmed.to_string()));\n }\n }\n }\n\n if snippet.is_empty() {\n return Ok(\"{}\".to_string());\n }\n\n serde_json::to_string_pretty(&Value::Object(snippet))\n .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n }\n\n /// Extract common config for OpenCode (JSON format)\n fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {\n // OpenCode uses a different config structure with npm, options, models\n // For common config, we exclude provider-specific fields like apiKey\n let mut config = settings.clone();\n\n // Remove provider-specific fields\n if let Some(obj) = config.as_object_mut() {\n if let Some(options) = obj.get_mut(\"options\").and_then(|v| v.as_object_mut()) {\n options.remove(\"apiKey\");\n options.remove(\"baseURL\");\n }\n // Keep npm and models as they might be common\n }\n\n if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {\n return Ok(\"{}\".to_string());\n }\n\n serde_json::to_string_pretty(&config)\n .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n }\n\n /// Extract common config for OpenClaw (JSON format)\n fn extract_openclaw_common_config(settings: &Value) -> Result<String, AppError> {\n // OpenClaw uses a different config structure with baseUrl, apiKey, api, models\n // For common config, we exclude provider-specific fields like apiKey\n let mut config = settings.clone();\n\n // Remove provider-specific fields\n if let Some(obj) = config.as_object_mut() {\n obj.remove(\"apiKey\");\n obj.remove(\"baseUrl\");\n // Keep api and models as they might be common\n }\n\n if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {\n return Ok(\"{}\".to_string());\n }\n\n serde_json::to_string_pretty(&config)\n .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n }\n\n /// Import default configuration from live files (re-export)\n ///\n /// Returns `Ok(true)` if imported, `Ok(false)` if skipped.\n pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {\n import_default_config(state, app_type)\n }\n\n pub fn should_import_default_config_on_startup(\n state: &AppState,\n app_type: &AppType,\n ) -> Result<bool, AppError> {\n should_import_default_config_on_startup(state, app_type)\n }\n\n /// Read current live settings (re-export)\n pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {\n read_live_settings(app_type)\n }\n\n /// Get custom endpoints list (re-export)\n pub fn get_custom_endpoints(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n ) -> Result<Vec<CustomEndpoint>, AppError> {\n endpoints::get_custom_endpoints(state, app_type, provider_id)\n }\n\n /// Add custom endpoint (re-export)\n pub fn add_custom_endpoint(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n url: String,\n ) -> Result<(), AppError> {\n endpoints::add_custom_endpoint(state, app_type, provider_id, url)\n }\n\n /// Remove custom endpoint (re-export)\n pub fn remove_custom_endpoint(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n url: String,\n ) -> Result<(), AppError> {\n endpoints::remove_custom_endpoint(state, app_type, provider_id, url)\n }\n\n /// Update endpoint last used timestamp (re-export)\n pub fn update_endpoint_last_used(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n url: String,\n ) -> Result<(), AppError> {\n endpoints::update_endpoint_last_used(state, app_type, provider_id, url)\n }\n\n /// Update provider sort order\n pub fn update_sort_order(\n state: &AppState,\n app_type: AppType,\n updates: Vec<ProviderSortUpdate>,\n ) -> Result<bool, AppError> {\n let mut providers = state.db.get_all_providers(app_type.as_str())?;\n\n for update in updates {\n if let Some(provider) = providers.get_mut(&update.id) {\n provider.sort_index = Some(update.sort_index);\n state.db.save_provider(app_type.as_str(), provider)?;\n }\n }\n\n Ok(true)\n }\n\n /// Query provider usage (re-export)\n pub async fn query_usage(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n ) -> Result<UsageResult, AppError> {\n usage::query_usage(state, app_type, provider_id).await\n }\n\n /// Test usage script (re-export)\n #[allow(clippy::too_many_arguments)]\n pub async fn test_usage_script(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n script_code: &str,\n timeout: u64,\n api_key: Option<&str>,\n base_url: Option<&str>,\n access_token: Option<&str>,\n user_id: Option<&str>,\n template_type: Option<&str>,\n ) -> Result<UsageResult, AppError> {\n usage::test_usage_script(\n state,\n app_type,\n provider_id,\n script_code,\n timeout,\n api_key,\n base_url,\n access_token,\n user_id,\n template_type,\n )\n .await\n }\n\n pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {\n write_gemini_live(provider)\n }\n\n fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {\n match app_type {\n AppType::Claude => {\n if !provider.settings_config.is_object() {\n return Err(AppError::localized(\n \"provider.claude.settings.not_object\",\n \"Claude 配置必须是 JSON 对象\",\n \"Claude configuration must be a JSON object\",\n ));\n }\n }\n AppType::ClaudeDesktop => {\n crate::claude_desktop_config::validate_provider(provider)?;\n }\n AppType::Codex => {\n let settings = provider.settings_config.as_object().ok_or_else(|| {\n AppError::localized(\n \"provider.codex.settings.not_object\",\n \"Codex 配置必须是 JSON 对象\",\n \"Codex configuration must be a JSON object\",\n )\n })?;\n\n let auth = settings.get(\"auth\").ok_or_else(|| {\n AppError::localized(\n \"provider.codex.auth.missing\",\n format!(\"供应商 {} 缺少 auth 配置\", provider.id),\n format!(\"Provider {} is missing auth configuration\", provider.id),\n )\n })?;\n if !auth.is_object() {\n return Err(AppError::localized(\n \"provider.codex.auth.not_object\",\n format!(\"供应商 {} 的 auth 配置必须是 JSON 对象\", provider.id),\n format!(\n \"Provider {} auth configuration must be a JSON object\",\n provider.id\n ),\n ));\n }\n\n if let Some(config_value) = settings.get(\"config\") {\n if !(config_value.is_string() || config_value.is_null()) {\n return Err(AppError::localized(\n \"provider.codex.config.invalid_type\",\n \"Codex config 字段必须是字符串\",\n \"Codex config field must be a string\",\n ));\n }\n if let Some(cfg_text) = config_value.as_str() {\n crate::codex_config::validate_config_toml(cfg_text)?;\n }\n }\n }\n AppType::Grok => {\n let settings = provider.settings_config.as_object().ok_or_else(|| {\n AppError::localized(\n \"provider.grok.settings.not_object\",\n \"Grok 配置必须是 JSON 对象\",\n \"Grok configuration must be a JSON object\",\n )\n })?;\n let auth = settings.get(\"auth\").ok_or_else(|| {\n AppError::localized(\n \"provider.grok.auth.missing\",\n format!(\"供应商 {} 缺少 auth 配置\", provider.id),\n format!(\"Provider {} is missing auth configuration\", provider.id),\n )\n })?;\n if !auth.is_object() {\n return Err(AppError::localized(\n \"provider.grok.auth.not_object\",\n \"Grok auth 配置必须是 JSON 对象\",\n \"Grok auth configuration must be a JSON object\",\n ));\n }\n let config = settings\n .get(\"config\")\n .and_then(Value::as_str)\n .ok_or_else(|| {\n AppError::localized(\n \"provider.grok.config.missing\",\n \"Grok config 字段必须是 TOML 字符串\",\n \"Grok config must be a TOML string\",\n )\n })?;\n crate::grok_config::validate_config_toml(config)?;\n }\n AppType::Gemini => {\n use crate::gemini_config::validate_gemini_settings;\n validate_gemini_settings(&provider.settings_config)?\n }\n AppType::OpenCode => {\n // OpenCode uses a different config structure: { npm, options, models }\n // Basic validation - must be an object\n if !provider.settings_config.is_object() {\n return Err(AppError::localized(\n \"provider.opencode.settings.not_object\",\n \"OpenCode 配置必须是 JSON 对象\",\n \"OpenCode configuration must be a JSON object\",\n ));\n }\n }\n AppType::OpenClaw => {\n // OpenClaw uses config structure: { baseUrl, apiKey, api, models }\n // Basic validation - must be an object\n if !provider.settings_config.is_object() {\n return Err(AppError::localized(\n \"provider.openclaw.settings.not_object\",\n \"OpenClaw 配置必须是 JSON 对象\",\n \"OpenClaw configuration must be a JSON object\",\n ));\n }\n }\n AppType::Hermes => {\n // Hermes: accept any JSON object for now\n if !provider.settings_config.is_object() {\n return Err(AppError::localized(\n \"provider.hermes.settings.not_object\",\n \"Hermes 配置必须是 JSON 对象\",\n \"Hermes configuration must be a JSON object\",\n ));\n }\n }\n }\n\n // Validate and clean UsageScript configuration (common for all app types)\n if let Some(meta) = &provider.meta {\n if let Some(multiplier) = meta.cost_multiplier.as_deref() {\n validate_cost_multiplier(multiplier)?;\n }\n if let Some(source) = meta.pricing_model_source.as_deref() {\n validate_pricing_source(source)?;\n }\n if let Some(usage_script) = &meta.usage_script {\n validate_usage_script(usage_script)?;\n }\n }\n\n Ok(())\n }\n\n #[allow(dead_code)]\n fn extract_credentials(\n provider: &Provider,\n app_type: &AppType,\n ) -> Result<(String, String), AppError> {\n match app_type {\n AppType::Claude => {\n let env = provider\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.claude.env.missing\",\n \"配置格式错误: 缺少 env\",\n \"Invalid configuration: missing env section\",\n )\n })?;\n\n let api_key = env\n .get(\"ANTHROPIC_AUTH_TOKEN\")\n .or_else(|| env.get(\"ANTHROPIC_API_KEY\"))\n .and_then(|v| v.as_str())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.claude.api_key.missing\",\n \"缺少 API Key\",\n \"API key is missing\",\n )\n })?\n .to_string();\n\n let base_url = env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.claude.base_url.missing\",\n \"缺少 ANTHROPIC_BASE_URL 配置\",\n \"Missing ANTHROPIC_BASE_URL configuration\",\n )\n })?\n .to_string();\n\n Ok((api_key, base_url))\n }\n AppType::ClaudeDesktop => {\n let credentials =\n crate::claude_desktop_config::direct_gateway_credentials(provider)?;\n Ok((credentials.api_key, credentials.base_url))\n }\n AppType::Codex => {\n let _auth = provider\n .settings_config\n .get(\"auth\")\n .and_then(|v| v.as_object())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.codex.auth.missing\",\n \"配置格式错误: 缺少 auth\",\n \"Invalid configuration: missing auth section\",\n )\n })?;\n\n let config_toml = provider\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n\n let api_key = crate::codex_config::extract_codex_api_key(\n provider.settings_config.get(\"auth\"),\n Some(config_toml),\n )\n .ok_or_else(|| {\n AppError::localized(\n \"provider.codex.api_key.missing\",\n \"缺少 API Key\",\n \"API key is missing\",\n )\n })?;\n\n let base_url = if config_toml.contains(\"base_url\") {\n let re = Regex::new(r#\"base_url\\s*=\\s*[\"']([^\"']+)[\"']\"#).map_err(|e| {\n AppError::localized(\n \"provider.regex_init_failed\",\n format!(\"正则初始化失败: {e}\"),\n format!(\"Failed to initialize regex: {e}\"),\n )\n })?;\n re.captures(config_toml)\n .and_then(|caps| caps.get(1))\n .map(|m| m.as_str().to_string())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.codex.base_url.invalid\",\n \"config.toml 中 base_url 格式错误\",\n \"base_url in config.toml has invalid format\",\n )\n })?\n } else {\n return Err(AppError::localized(\n \"provider.codex.base_url.missing\",\n \"config.toml 中缺少 base_url 配置\",\n \"base_url is missing from config.toml\",\n ));\n };\n\n Ok((api_key, base_url))\n }\n AppType::Grok => {\n let auth = provider\n .settings_config\n .get(\"auth\")\n .and_then(Value::as_object)\n .ok_or_else(|| {\n AppError::localized(\n \"provider.grok.auth.missing\",\n \"配置格式错误: 缺少 auth\",\n \"Invalid configuration: missing auth section\",\n )\n })?;\n let api_key = [\"OPENAI_API_KEY\", \"XAI_API_KEY\", \"GROK_API_KEY\"]\n .into_iter()\n .find_map(|key| auth.get(key).and_then(Value::as_str))\n .map(str::trim)\n .filter(|key| !key.is_empty())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.grok.api_key.missing\",\n \"缺少 API Key\",\n \"API key is missing\",\n )\n })?\n .to_string();\n let config = provider\n .settings_config\n .get(\"config\")\n .and_then(Value::as_str)\n .unwrap_or_default();\n let base_url = crate::grok_config::active_base_url(config).ok_or_else(|| {\n AppError::localized(\n \"provider.grok.base_url.missing\",\n \"Grok config.toml 中缺少 base_url 配置\",\n \"base_url is missing from Grok config.toml\",\n )\n })?;\n Ok((api_key, base_url))\n }\n AppType::Gemini => {\n use crate::gemini_config::json_to_env;\n\n let env_map = json_to_env(&provider.settings_config)?;\n\n let api_key = env_map.get(\"GEMINI_API_KEY\").cloned().ok_or_else(|| {\n AppError::localized(\n \"gemini.missing_api_key\",\n \"缺少 GEMINI_API_KEY\",\n \"Missing GEMINI_API_KEY\",\n )\n })?;\n\n let base_url = env_map\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .cloned()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com\".to_string());\n\n Ok((api_key, base_url))\n }\n AppType::OpenCode => {\n // OpenCode uses options.apiKey and options.baseURL\n let options = provider\n .settings_config\n .get(\"options\")\n .and_then(|v| v.as_object())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.opencode.options.missing\",\n \"配置格式错误: 缺少 options\",\n \"Invalid configuration: missing options section\",\n )\n })?;\n\n let api_key = options\n .get(\"apiKey\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.opencode.api_key.missing\",\n \"缺少 API Key\",\n \"API key is missing\",\n )\n })?\n .to_string();\n\n let base_url = options\n .get(\"baseURL\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\")\n .to_string();\n\n Ok((api_key, base_url))\n }\n AppType::OpenClaw | AppType::Hermes => {\n // OpenClaw/Hermes use apiKey and baseUrl directly on the object\n let api_key = provider\n .settings_config\n .get(\"apiKey\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.openclaw.api_key.missing\",\n \"缺少 API Key\",\n \"API key is missing\",\n )\n })?\n .to_string();\n\n let base_url = provider\n .settings_config\n .get(\"baseUrl\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\")\n .to_string();\n\n Ok((api_key, base_url))\n }\n }\n }\n}\n\n/// Normalize Claude model keys in a JSON value\n///\n/// Reads old key (ANTHROPIC_SMALL_FAST_MODEL), writes new keys (DEFAULT_*), and deletes old key.\npub(crate) fn normalize_claude_models_in_value(settings: &mut Value) -> bool {\n let mut changed = false;\n let env = match settings.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n Some(obj) => obj,\n None => return changed,\n };\n\n let model = env\n .get(\"ANTHROPIC_MODEL\")\n .and_then(|v| v.as_str())\n .map(|s| s.to_string());\n let small_fast = env\n .get(\"ANTHROPIC_SMALL_FAST_MODEL\")\n .and_then(|v| v.as_str())\n .map(|s| s.to_string());\n\n let current_haiku = env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str())\n .map(|s| s.to_string());\n let current_sonnet = env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str())\n .map(|s| s.to_string());\n let current_opus = env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str())\n .map(|s| s.to_string());\n\n let target_haiku = current_haiku\n .or_else(|| small_fast.clone())\n .or_else(|| model.clone());\n let target_sonnet = current_sonnet\n .or_else(|| model.clone())\n .or_else(|| small_fast.clone());\n let target_opus = current_opus\n .or_else(|| model.clone())\n .or_else(|| small_fast.clone());\n\n if env.get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\").is_none() {\n if let Some(v) = target_haiku {\n env.insert(\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\".to_string(),\n Value::String(v),\n );\n changed = true;\n }\n }\n if env.get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\").is_none() {\n if let Some(v) = target_sonnet {\n env.insert(\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\".to_string(),\n Value::String(v),\n );\n changed = true;\n }\n }\n if env.get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\").is_none() {\n if let Some(v) = target_opus {\n env.insert(\"ANTHROPIC_DEFAULT_OPUS_MODEL\".to_string(), Value::String(v));\n changed = true;\n }\n }\n\n if env.remove(\"ANTHROPIC_SMALL_FAST_MODEL\").is_some() {\n changed = true;\n }\n\n changed\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ProviderSortUpdate {\n pub id: String,\n #[serde(rename = \"sortIndex\")]\n pub sort_index: usize,\n}\n\n// ============================================================================\n// 统一供应商(Universal Provider)服务方法\n// ============================================================================\n\nuse crate::provider::UniversalProvider;\nuse std::collections::HashMap;\n\nimpl ProviderService {\n /// 获取所有统一供应商\n pub fn list_universal(\n state: &AppState,\n ) -> Result<HashMap<String, UniversalProvider>, AppError> {\n state.db.get_all_universal_providers()\n }\n\n /// 获取单个统一供应商\n pub fn get_universal(\n state: &AppState,\n id: &str,\n ) -> Result<Option<UniversalProvider>, AppError> {\n state.db.get_universal_provider(id)\n }\n\n /// 添加或更新统一供应商(不自动同步,需手动调用 sync_universal_to_apps)\n pub fn upsert_universal(\n state: &AppState,\n provider: UniversalProvider,\n ) -> Result<bool, AppError> {\n // 保存统一供应商\n state.db.save_universal_provider(&provider)?;\n\n Ok(true)\n }\n\n /// 删除统一供应商\n pub fn delete_universal(state: &AppState, id: &str) -> Result<bool, AppError> {\n // 获取统一供应商(用于删除生成的子供应商)\n let provider = state.db.get_universal_provider(id)?;\n\n // 删除统一供应商\n state.db.delete_universal_provider(id)?;\n\n // 删除生成的子供应商\n if let Some(p) = provider {\n if p.apps.claude {\n let claude_id = format!(\"universal-claude-{id}\");\n let _ = state.db.delete_provider(\"claude\", &claude_id);\n }\n if p.apps.codex {\n let codex_id = format!(\"universal-codex-{id}\");\n let _ = state.db.delete_provider(\"codex\", &codex_id);\n }\n if p.apps.gemini {\n let gemini_id = format!(\"universal-gemini-{id}\");\n let _ = state.db.delete_provider(\"gemini\", &gemini_id);\n }\n }\n\n Ok(true)\n }\n\n /// 同步统一供应商到各应用\n pub fn sync_universal_to_apps(state: &AppState, id: &str) -> Result<bool, AppError> {\n let provider = state\n .db\n .get_universal_provider(id)?\n .ok_or_else(|| AppError::Message(format!(\"统一供应商 {id} 不存在\")))?;\n\n // 同步到 Claude\n if let Some(mut claude_provider) = provider.to_claude_provider() {\n // 合并已有配置\n if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, \"claude\")? {\n let mut merged = existing.settings_config.clone();\n Self::merge_json(&mut merged, &claude_provider.settings_config);\n claude_provider.settings_config = merged;\n }\n state.db.save_provider(\"claude\", &claude_provider)?;\n } else {\n // 如果禁用了 Claude,删除对应的子供应商\n let claude_id = format!(\"universal-claude-{id}\");\n let _ = state.db.delete_provider(\"claude\", &claude_id);\n }\n\n // 同步到 Codex\n if let Some(mut codex_provider) = provider.to_codex_provider() {\n // 合并已有配置\n if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, \"codex\")? {\n let mut merged = existing.settings_config.clone();\n Self::merge_json(&mut merged, &codex_provider.settings_config);\n codex_provider.settings_config = merged;\n }\n state.db.save_provider(\"codex\", &codex_provider)?;\n } else {\n let codex_id = format!(\"universal-codex-{id}\");\n let _ = state.db.delete_provider(\"codex\", &codex_id);\n }\n\n // 同步到 Gemini\n if let Some(mut gemini_provider) = provider.to_gemini_provider() {\n // 合并已有配置\n if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, \"gemini\")? {\n let mut merged = existing.settings_config.clone();\n Self::merge_json(&mut merged, &gemini_provider.settings_config);\n gemini_provider.settings_config = merged;\n }\n state.db.save_provider(\"gemini\", &gemini_provider)?;\n } else {\n let gemini_id = format!(\"universal-gemini-{id}\");\n let _ = state.db.delete_provider(\"gemini\", &gemini_id);\n }\n\n Ok(true)\n }\n\n /// 递归合并 JSON:base 为底,patch 覆盖同名字段\n fn merge_json(base: &mut serde_json::Value, patch: &serde_json::Value) {\n use serde_json::Value;\n\n match (base, patch) {\n (Value::Object(base_map), Value::Object(patch_map)) => {\n for (k, v_patch) in patch_map {\n match base_map.get_mut(k) {\n Some(v_base) => Self::merge_json(v_base, v_patch),\n None => {\n base_map.insert(k.clone(), v_patch.clone());\n }\n }\n }\n }\n // 其它类型:直接覆盖\n (base_val, patch_val) => {\n *base_val = patch_val.clone();\n }\n }\n }\n}\n"}, "0950b45609411587ce879641b25ac84f8e452fef": {"t": 1785761094.7978501, "n": 155357, "h": "7f42e5f35c5ff8da", "c": "//! Provider service module\n//!\n//! Handles provider CRUD operations, switching, and configuration management.\n\nmod endpoints;\nmod gemini_auth;\nmod live;\nmod usage;\n\nuse indexmap::IndexMap;\nuse regex::Regex;\nuse serde::Deserialize;\nuse serde_json::Value;\n\nuse crate::app_config::AppType;\nuse crate::database::{validate_cost_multiplier, validate_pricing_source};\nuse crate::error::AppError;\nuse crate::provider::{Provider, UsageResult};\nuse crate::services::mcp::McpService;\nuse crate::settings::CustomEndpoint;\nuse crate::store::AppState;\n\n// Re-export sub-module functions for external access\npub use live::{\n import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live,\n import_opencode_providers_from_live, read_live_settings,\n should_import_default_config_on_startup, sync_current_to_live,\n update_toml_common_config_snippet,\n};\n\n// Internal re-exports (pub(crate))\npub(crate) use live::sanitize_claude_settings_for_live;\npub(crate) use live::{\n build_effective_settings_with_common_config, normalize_provider_common_config_for_storage,\n provider_exists_in_live_config, strip_common_config_from_live_settings,\n sync_current_provider_for_app_to_live, write_live_with_common_config,\n};\n\n// Internal re-exports\nuse live::{\n remove_hermes_provider_from_live, remove_openclaw_provider_from_live,\n remove_opencode_provider_from_live, write_gemini_live,\n};\nuse usage::validate_usage_script;\n\n/// The built-in Codex official provider is safe to select during takeover:\n/// Codex keeps ownership of its ChatGPT login and the proxy only forwards the\n/// authenticated request. Other official providers retain the existing block.\npub fn official_provider_supports_proxy_takeover(app_type: &AppType, provider: &Provider) -> bool {\n matches!(app_type, AppType::Codex)\n && crate::proxy::providers::is_codex_official_provider(provider)\n}\n\n/// 统一会话开关变更后,立即按新开关状态重写当前官方 Codex 供应商的\n/// live 配置,使开关即时生效(无需等下一次切换)。\n/// 当前供应商非官方(或不存在)时为 no-op:注入只作用于官方配置,\n/// 第三方 live 配置不受开关影响。\npub fn reapply_current_codex_official_live(state: &AppState) -> Result<bool, AppError> {\n let current_id = ProviderService::current(state, AppType::Codex)?;\n if current_id.is_empty() {\n return Ok(false);\n }\n let providers = state.db.get_all_providers(AppType::Codex.as_str())?;\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n if provider.category.as_deref() != Some(\"official\") {\n return Ok(false);\n }\n\n // 代理接管期间 live 归代理所有(开启代理时官方供应商只警告不拦截,\n // 二者可以共存)。与切换/保存路径一致:以 backup/占位符为所有权信号,\n // 只更新备份,注入后的配置由接管释放时的恢复路径落盘。\n let has_live_backup =\n futures::executor::block_on(state.db.get_live_backup(AppType::Codex.as_str()))\n .ok()\n .flatten()\n .is_some();\n let live_taken_over = state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex);\n if has_live_backup || live_taken_over {\n futures::executor::block_on(\n state\n .proxy_service\n .update_live_backup_from_provider(AppType::Codex.as_str(), provider),\n )\n .map_err(|e| AppError::Message(format!(\"更新 Live 备份失败: {e}\")))?;\n return Ok(true);\n }\n\n live::write_live_with_common_config(&state.db, &AppType::Codex, provider)?;\n // 重写 live 会整体替换 config.toml(有意设计),[mcp_servers] 随之丢失,\n // 写完必须立刻从 DB 重新投影启用的 MCP。只投影 Codex 而非\n // sync_all_enabled:后者按 AppType::all() 顺序逐应用短路,排在 Codex\n // 前面的无关应用 live 损坏(如 ~/.claude.json 坏 JSON)会阻断 Codex\n // 的重投影,让刚被清掉的 [mcp_servers] 无人补回。\n // 投影失败降级为警告:走到这里 live 已按新开关状态落盘,开关事实上\n // 已生效;若把错误上抛,save_settings 会回滚开关设置,制造\"设置=旧值、\n // live=新桶\"的会话分裂——正是该回滚要防止的状态。MCP 投影可自愈\n // (下次切换 / 任一 MCP 启停操作都会重新投影)。\n if let Err(err) = McpService::sync_enabled_for_app(state, &AppType::Codex) {\n log::warn!(\"统一会话开关重写 live 后重投影 Codex MCP 失败(将在下次同步时自愈): {err}\");\n }\n Ok(true)\n}\n\n/// Provider business logic service\npub struct ProviderService;\n\n/// Result of a provider switch operation, including any non-fatal warnings\n#[derive(Debug, serde::Serialize, Default)]\n#[serde(rename_all = \"camelCase\")]\npub struct SwitchResult {\n pub warnings: Vec<String>,\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n #[cfg(any(target_os = \"macos\", windows))]\n use crate::claude_desktop_config::PROFILE_ID;\n use crate::config::{get_claude_settings_path, read_json_file, write_json_file};\n use crate::database::Database;\n #[cfg(any(target_os = \"macos\", windows))]\n use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute};\n use crate::provider::{ProviderMeta, UsageScript};\n use crate::proxy::types::ProxyConfig;\n use crate::store::AppState;\n use serde_json::json;\n use serial_test::serial;\n use std::env;\n use std::fs;\n use std::path::{Path, PathBuf};\n use std::sync::{Arc, Mutex, OnceLock};\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: Option<String>,\n #[cfg(windows)]\n original_local_app_data: Option<String>,\n original_userprofile: Option<String>,\n original_test_home: Option<String>,\n }\n\n impl TempHome {\n fn new() -> Self {\n let dir = TempDir::new().expect(\"failed to create temp home\");\n let original_home = env::var(\"HOME\").ok();\n #[cfg(windows)]\n let original_local_app_data = env::var(\"LOCALAPPDATA\").ok();\n let original_userprofile = env::var(\"USERPROFILE\").ok();\n let original_test_home = env::var(\"CC_SWITCH_TEST_HOME\").ok();\n\n env::set_var(\"HOME\", dir.path());\n #[cfg(windows)]\n env::set_var(\"LOCALAPPDATA\", dir.path().join(\"AppData\").join(\"Local\"));\n env::set_var(\"USERPROFILE\", dir.path());\n env::set_var(\"CC_SWITCH_TEST_HOME\", dir.path());\n\n Self {\n dir,\n original_home,\n #[cfg(windows)]\n original_local_app_data,\n original_userprofile,\n original_test_home,\n }\n }\n }\n\n impl Drop for TempHome {\n fn drop(&mut self) {\n match &self.original_home {\n Some(value) => env::set_var(\"HOME\", value),\n None => env::remove_var(\"HOME\"),\n }\n\n #[cfg(windows)]\n {\n match &self.original_local_app_data {\n Some(value) => env::set_var(\"LOCALAPPDATA\", value),\n None => env::remove_var(\"LOCALAPPDATA\"),\n }\n }\n\n match &self.original_userprofile {\n Some(value) => env::set_var(\"USERPROFILE\", value),\n None => env::remove_var(\"USERPROFILE\"),\n }\n\n match &self.original_test_home {\n Some(value) => env::set_var(\"CC_SWITCH_TEST_HOME\", value),\n None => env::remove_var(\"CC_SWITCH_TEST_HOME\"),\n }\n }\n }\n\n #[cfg(windows)]\n fn claude_desktop_profile_path(home: &Path) -> PathBuf {\n home.join(\"AppData\")\n .join(\"Local\")\n .join(\"Claude-3p\")\n .join(\"configLibrary\")\n .join(format!(\"{PROFILE_ID}.json\"))\n }\n\n #[cfg(target_os = \"macos\")]\n fn claude_desktop_profile_path(home: &Path) -> PathBuf {\n home.join(\"Library\")\n .join(\"Application Support\")\n .join(\"Claude-3p\")\n .join(\"configLibrary\")\n .join(format!(\"{PROFILE_ID}.json\"))\n }\n\n fn test_guard() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock<Mutex<()>> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|err| err.into_inner())\n }\n\n fn with_test_home<T>(test: impl FnOnce(&AppState, &Path) -> T) -> T {\n let _guard = test_guard();\n let temp = tempfile::tempdir().expect(\"tempdir\");\n let old_test_home = std::env::var_os(\"CC_SWITCH_TEST_HOME\");\n let old_home = std::env::var_os(\"HOME\");\n std::env::set_var(\"CC_SWITCH_TEST_HOME\", temp.path());\n std::env::set_var(\"HOME\", temp.path());\n\n let db = Arc::new(Database::memory().expect(\"in-memory database\"));\n let state = AppState::new(db);\n let result = test(&state, temp.path());\n\n match old_test_home {\n Some(value) => std::env::set_var(\"CC_SWITCH_TEST_HOME\", value),\n None => std::env::remove_var(\"CC_SWITCH_TEST_HOME\"),\n }\n match old_home {\n Some(value) => std::env::set_var(\"HOME\", value),\n None => std::env::remove_var(\"HOME\"),\n }\n\n result\n }\n\n fn codex_settings(base_url: &str, api_key: &str) -> Value {\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": api_key\n },\n \"config\": format!(\n \"model_provider = \\\"custom\\\"\\n\\\n [model_providers.custom]\\n\\\n name = \\\"custom\\\"\\n\\\n base_url = \\\"{base_url}\\\"\\n\\\n wire_api = \\\"chat\\\"\\n\"\n )\n })\n }\n\n fn usage_script_with_credentials(\n api_key: Option<&str>,\n base_url: Option<&str>,\n template_type: Option<&str>,\n ) -> UsageScript {\n UsageScript {\n enabled: true,\n language: \"javascript\".to_string(),\n code: \"return { remaining: 1, unit: 'USD' };\".to_string(),\n timeout: Some(10),\n api_key: api_key.map(str::to_string),\n base_url: base_url.map(str::to_string),\n access_token: None,\n user_id: None,\n template_type: template_type.map(str::to_string),\n auto_query_interval: None,\n coding_plan_provider: None,\n access_key_id: Some(\"ak-test\".to_string()),\n secret_access_key: Some(\"sk-test\".to_string()),\n team_organization_id: None,\n team_project_id: None,\n }\n }\n\n fn codex_provider_with_usage(\n id: &str,\n base_url: &str,\n api_key: &str,\n usage_api_key: Option<&str>,\n usage_base_url: Option<&str>,\n template_type: Option<&str>,\n ) -> Provider {\n let mut provider = Provider::with_id(\n id.to_string(),\n format!(\"Provider {id}\"),\n codex_settings(base_url, api_key),\n None,\n );\n provider.meta = Some(ProviderMeta {\n usage_script: Some(usage_script_with_credentials(\n usage_api_key,\n usage_base_url,\n template_type,\n )),\n ..Default::default()\n });\n provider\n }\n\n fn openclaw_provider(id: &str) -> Provider {\n Provider {\n id: id.to_string(),\n name: format!(\"Provider {id}\"),\n settings_config: json!({\n \"baseUrl\": \"https://api.deepseek.com\",\n \"apiKey\": \"test-key\",\n \"api\": \"openai-completions\",\n \"models\": [],\n }),\n website_url: None,\n category: Some(\"custom\".to_string()),\n created_at: Some(1),\n sort_index: Some(0),\n notes: None,\n meta: None,\n icon: None,\n icon_color: None,\n in_failover_queue: false,\n }\n }\n\n fn hermes_provider(id: &str) -> Provider {\n Provider {\n id: id.to_string(),\n name: format!(\"Provider {id}\"),\n settings_config: json!({\n \"api\": \"openai-chat\",\n \"base_url\": \"https://api.example.com/v1\",\n \"api_key\": \"test-key\",\n \"models\": {\n \"gpt-4o\": {\n \"name\": \"GPT-4o\"\n }\n }\n }),\n website_url: None,\n category: Some(\"custom\".to_string()),\n created_at: Some(1),\n sort_index: Some(0),\n notes: None,\n meta: None,\n icon: None,\n icon_color: None,\n in_failover_queue: false,\n }\n }\n\n fn opencode_provider(id: &str) -> Provider {\n Provider {\n id: id.to_string(),\n name: format!(\"Provider {id}\"),\n settings_config: json!({\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"name\": format!(\"Provider {id}\"),\n \"options\": {\n \"baseURL\": \"https://api.example.com/v1\",\n \"apiKey\": \"test-key\"\n },\n \"models\": {\n \"gpt-4o\": {\n \"name\": \"GPT-4o\"\n }\n }\n }),\n website_url: None,\n category: Some(\"custom\".to_string()),\n created_at: Some(1),\n sort_index: Some(0),\n notes: None,\n meta: None,\n icon: None,\n icon_color: None,\n in_failover_queue: false,\n }\n }\n\n fn opencode_omo_provider(id: &str, category: &str) -> Provider {\n let mut settings = serde_json::Map::new();\n settings.insert(\n \"agents\".to_string(),\n json!({\n \"writer\": {\n \"model\": \"gpt-4o-mini\"\n }\n }),\n );\n if category == \"omo\" {\n settings.insert(\n \"categories\".to_string(),\n json!({\n \"default\": [\"writer\"]\n }),\n );\n }\n settings.insert(\n \"otherFields\".to_string(),\n json!({\n \"theme\": \"dark\"\n }),\n );\n\n Provider {\n id: id.to_string(),\n name: format!(\"Provider {id}\"),\n settings_config: Value::Object(settings),\n website_url: None,\n category: Some(category.to_string()),\n created_at: Some(1),\n sort_index: Some(0),\n notes: None,\n meta: None,\n icon: None,\n icon_color: None,\n in_failover_queue: false,\n }\n }\n\n fn omo_config_path(home: &Path, category: &str) -> PathBuf {\n home.join(\".config\").join(\"opencode\").join(match category {\n \"omo\" => crate::services::omo::STANDARD.preferred_filename,\n \"omo-slim\" => crate::services::omo::SLIM.preferred_filename,\n other => panic!(\"unexpected OMO category in test: {other}\"),\n })\n }\n\n #[test]\n #[serial]\n fn add_clears_usage_credentials_that_match_provider_config() {\n with_test_home(|state, _| {\n let provider = codex_provider_with_usage(\n \"codex-a\",\n \"https://api.a.example/v1/\",\n \"sk-a\",\n Some(\" sk-a \"),\n Some(\" https://api.a.example/v1/ \"),\n None,\n );\n\n ProviderService::add(state, AppType::Codex, provider, false).expect(\"add provider\");\n\n let saved = state\n .db\n .get_provider_by_id(\"codex-a\", AppType::Codex.as_str())\n .expect(\"query saved provider\")\n .expect(\"saved provider should exist\");\n let script = saved\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script.api_key, None);\n assert_eq!(script.base_url, None);\n });\n }\n\n #[test]\n #[serial]\n fn update_preserves_usage_credentials_that_only_match_previous_config() {\n with_test_home(|state, _| {\n let provider = codex_provider_with_usage(\n \"codex-usage-old\",\n \"https://api.a.example/v1/\",\n \"sk-a\",\n Some(\"sk-a\"),\n Some(\"https://api.a.example/v1/\"),\n None,\n );\n state\n .db\n .save_provider(AppType::Codex.as_str(), &provider)\n .expect(\"seed provider with explicit usage credentials\");\n\n let mut updated = provider.clone();\n updated.settings_config = codex_settings(\"https://api.b.example/v1/\", \"sk-b\");\n\n ProviderService::update(state, AppType::Codex, None, updated)\n .expect(\"update provider main credentials\");\n\n let saved = state\n .db\n .get_provider_by_id(\"codex-usage-old\", AppType::Codex.as_str())\n .expect(\"query updated provider\")\n .expect(\"updated provider should exist\");\n let script = saved\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script.api_key.as_deref(), Some(\"sk-a\"));\n assert_eq!(\n script.base_url.as_deref(),\n Some(\"https://api.a.example/v1/\")\n );\n assert_eq!(\n saved.resolve_usage_credentials(&AppType::Codex),\n (\"https://api.b.example/v1\".to_string(), \"sk-b\".to_string())\n );\n });\n }\n\n #[test]\n #[serial]\n fn copied_provider_uses_edited_credentials_after_add_clears_mirrored_usage_credentials() {\n with_test_home(|state, _| {\n let copied_provider = codex_provider_with_usage(\n \"codex-copy\",\n \"https://api.a.example/v1/\",\n \"sk-a\",\n Some(\"sk-a\"),\n Some(\"https://api.a.example/v1/\"),\n None,\n );\n\n ProviderService::add(state, AppType::Codex, copied_provider, false)\n .expect(\"add copied provider\");\n\n let saved_after_add = state\n .db\n .get_provider_by_id(\"codex-copy\", AppType::Codex.as_str())\n .expect(\"query copied provider\")\n .expect(\"copied provider should exist\");\n let script_after_add = saved_after_add\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n assert_eq!(script_after_add.api_key, None);\n assert_eq!(script_after_add.base_url, None);\n\n let mut edited_provider = saved_after_add.clone();\n edited_provider.settings_config = codex_settings(\"https://api.b.example/v1/\", \"sk-b\");\n\n ProviderService::update(state, AppType::Codex, None, edited_provider)\n .expect(\"edit copied provider credentials\");\n\n let saved_after_update = state\n .db\n .get_provider_by_id(\"codex-copy\", AppType::Codex.as_str())\n .expect(\"query edited provider\")\n .expect(\"edited provider should exist\");\n let script_after_update = saved_after_update\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script_after_update.api_key, None);\n assert_eq!(script_after_update.base_url, None);\n assert_eq!(\n saved_after_update.resolve_usage_credentials(&AppType::Codex),\n (\"https://api.b.example/v1\".to_string(), \"sk-b\".to_string())\n );\n });\n }\n\n #[test]\n #[serial]\n fn update_clears_usage_credentials_that_match_current_config() {\n with_test_home(|state, _| {\n let provider = codex_provider_with_usage(\n \"codex-current\",\n \"https://api.a.example/v1\",\n \"sk-a\",\n Some(\"sk-usage\"),\n Some(\"https://usage.example/api\"),\n None,\n );\n state\n .db\n .save_provider(AppType::Codex.as_str(), &provider)\n .expect(\"seed provider with distinct usage credentials\");\n\n let mut updated = provider.clone();\n updated.settings_config = codex_settings(\"https://api.b.example/v1/\", \"sk-b\");\n updated.meta = Some(ProviderMeta {\n usage_script: Some(usage_script_with_credentials(\n Some(\" sk-b \"),\n Some(\" https://api.b.example/v1/ \"),\n None,\n )),\n ..Default::default()\n });\n\n ProviderService::update(state, AppType::Codex, None, updated)\n .expect(\"update provider with redundant usage credentials\");\n\n let saved = state\n .db\n .get_provider_by_id(\"codex-current\", AppType::Codex.as_str())\n .expect(\"query updated provider\")\n .expect(\"updated provider should exist\");\n let script = saved\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script.api_key, None);\n assert_eq!(script.base_url, None);\n });\n }\n\n #[test]\n #[serial]\n fn add_preserves_distinct_usage_credentials() {\n with_test_home(|state, _| {\n let provider = codex_provider_with_usage(\n \"codex-distinct\",\n \"https://api.main.example/v1\",\n \"sk-main\",\n Some(\"sk-usage\"),\n Some(\"https://usage.example/api\"),\n None,\n );\n\n ProviderService::add(state, AppType::Codex, provider, false).expect(\"add provider\");\n\n let saved = state\n .db\n .get_provider_by_id(\"codex-distinct\", AppType::Codex.as_str())\n .expect(\"query saved provider\")\n .expect(\"saved provider should exist\");\n let script = saved\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script.api_key.as_deref(), Some(\"sk-usage\"));\n assert_eq!(\n script.base_url.as_deref(),\n Some(\"https://usage.example/api\")\n );\n });\n }\n\n #[test]\n #[serial]\n fn add_does_not_clear_token_plan_credentials() {\n with_test_home(|state, _| {\n let provider = codex_provider_with_usage(\n \"codex-token-plan\",\n \"https://api.plan.example/v1\",\n \"sk-plan\",\n Some(\"sk-plan\"),\n Some(\"https://api.plan.example/v1\"),\n Some(\"token_plan\"),\n );\n\n ProviderService::add(state, AppType::Codex, provider, false).expect(\"add provider\");\n\n let saved = state\n .db\n .get_provider_by_id(\"codex-token-plan\", AppType::Codex.as_str())\n .expect(\"query saved provider\")\n .expect(\"saved provider should exist\");\n let script = saved\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script.api_key.as_deref(), Some(\"sk-plan\"));\n assert_eq!(\n script.base_url.as_deref(),\n Some(\"https://api.plan.example/v1\")\n );\n assert_eq!(script.access_key_id.as_deref(), Some(\"ak-test\"));\n assert_eq!(script.secret_access_key.as_deref(), Some(\"sk-test\"));\n });\n }\n\n #[test]\n fn validate_provider_settings_rejects_missing_auth() {\n let provider = Provider::with_id(\n \"codex\".into(),\n \"Codex\".into(),\n json!({ \"config\": \"base_url = \\\"https://example.com\\\"\" }),\n None,\n );\n let err = ProviderService::validate_provider_settings(&AppType::Codex, &provider)\n .expect_err(\"missing auth should be rejected\");\n assert!(\n err.to_string().contains(\"auth\"),\n \"expected auth error, got {err:?}\"\n );\n }\n\n #[test]\n fn extract_claude_common_config_strips_all_credentials_keeps_shareable() {\n // env 混入多种凭据(Anthropic/OpenRouter/Google/OpenAI/Gemini + AWS/Vertex)\n // 与可共享配置;顶层混入非标准的 apiKey/api_key 凭据与正常设置。\n let settings = json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"sk-ant\",\n \"ANTHROPIC_AUTH_TOKEN\": \"tok-ant\",\n \"OPENROUTER_API_KEY\": \"sk-or\",\n \"GOOGLE_API_KEY\": \"g-key\",\n \"OPENAI_API_KEY\": \"sk-oai\",\n \"GEMINI_API_KEY\": \"g-gem\",\n \"AWS_ACCESS_KEY_ID\": \"AKIA\",\n \"AWS_SECRET_ACCESS_KEY\": \"secret\",\n \"AWS_SESSION_TOKEN\": \"sess\",\n \"GOOGLE_APPLICATION_CREDENTIALS\": \"/path/creds.json\",\n \"AWS_BEARER_TOKEN_BEDROCK\": \"bedrock-tok\",\n \"ANTHROPIC_BASE_URL\": \"https://example.com\",\n \"ANTHROPIC_MODEL\": \"claude-x\",\n \"CLAUDE_CODE_SUBAGENT_MODEL\": \"gpt-5.4-mini\",\n \"CLAUDE_CODE_MAX_CONTEXT_TOKENS\": \"400000\",\n \"CLAUDE_CODE_AUTO_COMPACT_WINDOW\": \"400000\",\n // 可共享、非机密配置(复数 _TOKENS 不应被误剥)\n \"ENABLE_TOOL_SEARCH\": \"true\",\n \"CLAUDE_CODE_MAX_OUTPUT_TOKENS\": \"8192\"\n },\n \"apiKey\": \"sk-top\",\n \"api_key\": \"sk-top2\",\n \"theme\": \"dark\",\n \"includeCoAuthoredBy\": false\n });\n\n let snippet = ProviderService::extract_claude_common_config(&settings)\n .expect(\"extract should succeed\");\n let value: Value = serde_json::from_str(&snippet).expect(\"snippet is valid JSON\");\n\n // 所有凭据都不得出现在共享片段里\n let env = value.get(\"env\");\n for leaked in [\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_AUTH_TOKEN\",\n \"OPENROUTER_API_KEY\",\n \"GOOGLE_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"AWS_ACCESS_KEY_ID\",\n \"AWS_SECRET_ACCESS_KEY\",\n \"AWS_SESSION_TOKEN\",\n \"GOOGLE_APPLICATION_CREDENTIALS\",\n \"AWS_BEARER_TOKEN_BEDROCK\",\n ] {\n assert!(\n env.and_then(|e| e.get(leaked)).is_none(),\n \"credential {leaked} must not leak into common config\"\n );\n }\n assert!(\n value.get(\"apiKey\").is_none() && value.get(\"api_key\").is_none(),\n \"top-level credentials must be stripped\"\n );\n\n // 端点/模型(provider-specific 非机密)也应剥掉\n assert!(env.and_then(|e| e.get(\"ANTHROPIC_BASE_URL\")).is_none());\n assert!(env.and_then(|e| e.get(\"ANTHROPIC_MODEL\")).is_none());\n assert!(env\n .and_then(|e| e.get(\"CLAUDE_CODE_SUBAGENT_MODEL\"))\n .is_none());\n assert!(env\n .and_then(|e| e.get(\"CLAUDE_CODE_MAX_CONTEXT_TOKENS\"))\n .is_none());\n assert!(env\n .and_then(|e| e.get(\"CLAUDE_CODE_AUTO_COMPACT_WINDOW\"))\n .is_none());\n\n // 可共享的非机密配置必须保留(含复数 _TOKENS 不被误剥)\n assert_eq!(\n env.and_then(|e| e.get(\"ENABLE_TOOL_SEARCH\"))\n .and_then(|v| v.as_str()),\n Some(\"true\")\n );\n assert_eq!(\n env.and_then(|e| e.get(\"CLAUDE_CODE_MAX_OUTPUT_TOKENS\"))\n .and_then(|v| v.as_str()),\n Some(\"8192\")\n );\n assert_eq!(value.get(\"theme\").and_then(|v| v.as_str()), Some(\"dark\"));\n assert_eq!(value.get(\"includeCoAuthoredBy\"), Some(&json!(false)));\n }\n\n /// Regression for issue #4272: Fable tier env keys must not enter the shared\n /// Claude common-config snippet (same class as haiku/sonnet/opus model pins).\n #[test]\n fn extract_claude_common_config_strips_fable_model_env_keys() {\n let settings = json!({\n \"env\": {\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"haiku-mapped\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Haiku Mapped\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"sonnet-mapped[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Sonnet Mapped\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"opus-mapped[1M]\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Opus Mapped\",\n \"ANTHROPIC_DEFAULT_FABLE_MODEL\": \"deepseek-v4-flash[1M]\",\n \"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME\": \"deepseek-v4-flash\",\n \"ANTHROPIC_MODEL\": \"default-mapped\",\n \"ENABLE_TOOL_SEARCH\": \"true\"\n },\n \"theme\": \"dark\"\n });\n\n let snippet = ProviderService::extract_claude_common_config(&settings)\n .expect(\"extract should succeed\");\n let value: Value = serde_json::from_str(&snippet).expect(\"snippet is valid JSON\");\n let env = value.get(\"env\");\n\n for stripped in [\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n \"ANTHROPIC_DEFAULT_FABLE_MODEL\",\n \"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME\",\n \"ANTHROPIC_MODEL\",\n ] {\n assert!(\n env.and_then(|e| e.get(stripped)).is_none(),\n \"provider-specific model key {stripped} must not enter common config\"\n );\n }\n\n assert_eq!(\n env.and_then(|e| e.get(\"ENABLE_TOOL_SEARCH\"))\n .and_then(|v| v.as_str()),\n Some(\"true\")\n );\n assert_eq!(value.get(\"theme\").and_then(|v| v.as_str()), Some(\"dark\"));\n }\n\n #[test]\n fn validate_provider_settings_rejects_negative_cost_multiplier() {\n let mut provider = Provider::with_id(\n \"claude\".into(),\n \"Claude\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"token\",\n \"ANTHROPIC_BASE_URL\": \"https://claude.example\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n cost_multiplier: Some(\"-1\".to_string()),\n ..ProviderMeta::default()\n });\n\n let err = ProviderService::validate_provider_settings(&AppType::Claude, &provider)\n .expect_err(\"negative multiplier should be rejected\");\n assert!(matches!(\n err,\n AppError::Localized {\n key: \"error.invalidMultiplier\",\n ..\n }\n ));\n }\n\n #[test]\n fn extract_credentials_returns_expected_values() {\n let provider = Provider::with_id(\n \"claude\".into(),\n \"Claude\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"token\",\n \"ANTHROPIC_BASE_URL\": \"https://claude.example\"\n }\n }),\n None,\n );\n let (api_key, base_url) =\n ProviderService::extract_credentials(&provider, &AppType::Claude).unwrap();\n assert_eq!(api_key, \"token\");\n assert_eq!(base_url, \"https://claude.example\");\n }\n\n #[test]\n fn extract_codex_common_config_strips_provider_fields_and_injected_artifacts() {\n // 顶层 experimental_bearer_token 模拟无活跃路由时的 fallback 注入;\n // web_search = \"disabled\" 是 cc-switch 对黑名单网关注入的哨兵;\n // 顶层 wire_api 模拟无 model_provider 时的 fallback 写法;\n // [mcp.servers] 是历史错误格式,sync_all_enabled 清不掉它。\n let config_toml = r#\"model_provider = \"azure\"\nmodel = \"gpt-4\"\nwire_api = \"chat\"\ndisable_response_storage = true\nexperimental_bearer_token = \"sk-live-secret\"\nmodel_catalog_json = \"cc-switch-model-catalog.json\"\nweb_search = \"disabled\"\n\n[model_providers.azure]\nname = \"Azure OpenAI\"\nbase_url = \"https://azure.example/v1\"\nwire_api = \"responses\"\n\n[mcp_servers.my_server]\nbase_url = \"http://localhost:8080\"\n\n[mcp.servers.legacy_server]\ncommand = \"legacy-cmd\"\n\"#;\n\n let settings = json!({ \"config\": config_toml });\n let extracted = ProviderService::extract_codex_common_config(&settings)\n .expect(\"extract_codex_common_config should succeed\");\n\n assert!(\n !extracted\n .lines()\n .any(|line| line.trim_start().starts_with(\"model_provider\")),\n \"should remove top-level model_provider\"\n );\n assert!(\n !extracted\n .lines()\n .any(|line| line.trim_start().starts_with(\"model =\")),\n \"should remove top-level model\"\n );\n assert!(\n !extracted.contains(\"[model_providers\"),\n \"should remove entire model_providers table\"\n );\n // MCP 归 DB mcp_servers 表所有,不得进共享片段(含历史错误格式 [mcp.servers])\n assert!(\n !extracted.contains(\"mcp_servers\") && !extracted.contains(\"http://localhost:8080\"),\n \"should strip mcp_servers from the shared snippet, got: {extracted}\"\n );\n assert!(\n !extracted.contains(\"[mcp\") && !extracted.contains(\"legacy-cmd\"),\n \"should strip the legacy [mcp.servers] form from the shared snippet, got: {extracted}\"\n );\n // 顶层 wire_api 是供应商路由语义(model_providers 整表已剥,\n // 剩余任何 wire_api 都意味着泄漏)\n assert!(\n !extracted.contains(\"wire_api\"),\n \"should strip top-level wire_api from the shared snippet, got: {extracted}\"\n );\n // 注入产物不得进共享片段(bearer token 泄漏为密钥级问题)\n assert!(\n !extracted.contains(\"experimental_bearer_token\")\n && !extracted.contains(\"sk-live-secret\"),\n \"should strip top-level fallback bearer token, got: {extracted}\"\n );\n assert!(\n !extracted.contains(\"model_catalog_json\"),\n \"should strip catalog projection pointer, got: {extracted}\"\n );\n assert!(\n !extracted.contains(\"web_search\"),\n \"should strip the cc-switch web_search disabled sentinel, got: {extracted}\"\n );\n // 真正可共享的键保留\n assert!(\n extracted.contains(\"disable_response_storage = true\"),\n \"shareable keys must survive extraction, got: {extracted}\"\n );\n }\n\n #[test]\n fn extract_codex_common_config_keeps_user_set_web_search() {\n let config_toml = \"web_search = \\\"enabled\\\"\\ndisable_response_storage = true\\n\";\n let settings = json!({ \"config\": config_toml });\n let extracted = ProviderService::extract_codex_common_config(&settings)\n .expect(\"extract should succeed\");\n assert!(\n extracted.contains(\"web_search = \\\"enabled\\\"\"),\n \"a user-set web_search value is a shareable preference, got: {extracted}\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_current_claude_provider_syncs_live_when_proxy_takeover_detected_without_backup()\n {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = AppState::new(db.clone());\n\n let original = Provider::with_id(\n \"p1\".into(),\n \"Claude A\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"token-a\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"model-a\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n db.save_provider(\"claude\", &original)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"p1\"))\n .expect(\"set local current provider\");\n\n db.update_proxy_config(ProxyConfig {\n live_takeover_active: true,\n listen_port: 0,\n ..Default::default()\n })\n .await\n .expect(\"update proxy config\");\n {\n let mut config = db\n .get_proxy_config_for_app(\"claude\")\n .await\n .expect(\"get app proxy config\");\n config.enabled = true;\n db.update_proxy_config_for_app(config)\n .await\n .expect(\"update app proxy config\");\n }\n\n write_json_file(\n &get_claude_settings_path(),\n &json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": \"PROXY_MANAGED\",\n \"ANTHROPIC_MODEL\": \"stale-model\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n )\n .expect(\"seed taken-over live file\");\n\n let proxy_info = state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy service\");\n\n let updated = Provider::with_id(\n \"p1\".into(),\n \"Claude A\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"token-updated\",\n \"ANTHROPIC_BASE_URL\": \"https://api.updated.example\",\n \"ANTHROPIC_MODEL\": \"model-updated\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n ProviderService::update(&state, AppType::Claude, None, updated.clone())\n .expect(\"update current provider\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored_provider = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get stored provider\")\n .expect(\"stored provider exists\");\n let expected_backup =\n serde_json::to_string(&stored_provider.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected_backup);\n\n let live: Value = read_json_file(&get_claude_settings_path()).expect(\"read live\");\n assert_eq!(\n live.get(\"permissions\"),\n updated.settings_config.get(\"permissions\"),\n \"provider edits should propagate into Claude live config during takeover\"\n );\n assert_eq!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"PROXY_MANAGED\"),\n \"takeover placeholder should stay intact\"\n );\n assert_eq!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_BASE_URL\"))\n .and_then(|v| v.as_str()),\n Some(format!(\"http://127.0.0.1:{}\", proxy_info.port).as_str()),\n \"proxy base URL should stay intact\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"model override should be removed in takeover live config\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_current_codex_provider_refreshes_and_clears_catalog_during_takeover() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = AppState::new(db.clone());\n\n let mut original = Provider::with_id(\n \"p1\".into(),\n \"Codex A\".into(),\n json!({\n \"auth\": { \"OPENAI_API_KEY\": \"token-a\" },\n \"config\": r#\"model_provider = \"custom\"\nmodel = \"old-model\"\n\n[model_providers.custom]\nname = \"Codex A\"\nbase_url = \"https://api.a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#,\n \"modelCatalog\": {\n \"models\": [{ \"model\": \"old-model\" }]\n }\n }),\n None,\n );\n original.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".into()),\n ..Default::default()\n });\n db.save_provider(\"codex\", &original).expect(\"save provider\");\n db.set_current_provider(\"codex\", \"p1\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"p1\"))\n .expect(\"set local current provider\");\n\n db.update_proxy_config(ProxyConfig {\n live_takeover_active: true,\n listen_port: 0,\n ..Default::default()\n })\n .await\n .expect(\"update proxy config\");\n {\n let mut config = db\n .get_proxy_config_for_app(\"codex\")\n .await\n .expect(\"get app proxy config\");\n config.enabled = true;\n db.update_proxy_config_for_app(config)\n .await\n .expect(\"enable Codex proxy config\");\n }\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&original.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy service\");\n state\n .proxy_service\n .sync_codex_live_from_provider_while_proxy_active(&original)\n .await\n .expect(\"seed taken-over Codex live config\");\n assert!(\n state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded Codex live config should be recognized as takeover-owned\"\n );\n\n let mut updated = original.clone();\n updated.settings_config[\"config\"] = json!(\n r#\"model_provider = \"custom\"\nmodel = \"gpt-5.4\"\n\n[model_providers.custom]\nname = \"Codex A\"\nbase_url = \"https://api.updated.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n );\n updated.settings_config[\"modelCatalog\"] = json!({\n \"models\": [{ \"model\": \"gpt-5.4\", \"displayName\": \"GPT 5.4\" }]\n });\n\n ProviderService::update(&state, AppType::Codex, None, updated.clone())\n .expect(\"update current Codex provider mapping\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n let catalog: Value = read_json_file(&catalog_path).expect(\"read generated catalog\");\n assert_eq!(catalog[\"models\"][0][\"slug\"], \"gpt-5.4\");\n assert_eq!(\n catalog[\"models\"][0][\"input_modalities\"],\n json!([\"text\", \"image\"]),\n \"unknown/GPT models must fail open to image input\"\n );\n let live_config = fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read Codex config.toml\");\n assert!(live_config.contains(\"model_catalog_json\"));\n\n updated.settings_config[\"modelCatalog\"] = json!({ \"models\": [] });\n ProviderService::update(&state, AppType::Codex, None, updated)\n .expect(\"remove current Codex provider mapping\");\n\n let live_config = fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read Codex config.toml after mapping removal\");\n assert!(\n !live_config.contains(\"model_catalog_json\"),\n \"removing mappings during takeover must clear the stale catalog pointer\"\n );\n\n state\n .proxy_service\n .stop()\n .await\n .expect(\"stop proxy service\");\n }\n\n #[cfg(any(target_os = \"macos\", windows))]\n #[tokio::test]\n #[serial]\n async fn update_current_claude_desktop_provider_syncs_profile_when_proxy_takeover_is_active() {\n let home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = AppState::new(db.clone());\n\n let mut original = Provider::with_id(\n \"p1\".into(),\n \"Desktop A\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"token-a\",\n \"ANTHROPIC_BASE_URL\": \"https://opencode.ai/zen/go\"\n }\n }),\n None,\n );\n original.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".into()),\n claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),\n claude_desktop_model_routes: std::collections::HashMap::from([(\n \"claude-sonnet-4-6\".into(),\n ClaudeDesktopModelRoute {\n model: \"deepseek-v4-flash\".into(),\n label_override: Some(\"DeepSeek V4 Flash\".into()),\n supports_1m: None,\n },\n )]),\n ..Default::default()\n });\n db.save_provider(\"claude-desktop\", &original)\n .expect(\"save provider\");\n db.set_current_provider(\"claude-desktop\", \"p1\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::ClaudeDesktop, Some(\"p1\"))\n .expect(\"set local current provider\");\n\n // Claude Desktop keeps backup state from takeover startup; this sentinel only\n // marks takeover as active so provider updates rewrite the 3P profile.\n db.save_live_backup(\"claude-desktop\", \"{}\")\n .await\n .expect(\"seed live backup\");\n {\n let mut config = db\n .get_proxy_config_for_app(\"claude-desktop\")\n .await\n .expect(\"get app proxy config\");\n config.enabled = true;\n db.update_proxy_config_for_app(config)\n .await\n .expect(\"update app proxy config\");\n }\n\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy service\");\n\n let mut updated = Provider::with_id(\n \"p1\".into(),\n \"Desktop A\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"token-updated\",\n \"ANTHROPIC_BASE_URL\": \"https://opencode.ai/zen/go\"\n }\n }),\n None,\n );\n updated.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".into()),\n claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),\n claude_desktop_model_routes: std::collections::HashMap::from([(\n \"claude-sonnet-4-6\".into(),\n ClaudeDesktopModelRoute {\n model: \"deepseek-v4-flash\".into(),\n label_override: Some(\"DeepSeek V4 Flash Updated\".into()),\n supports_1m: Some(true),\n },\n )]),\n ..Default::default()\n });\n\n ProviderService::update(&state, AppType::ClaudeDesktop, None, updated.clone())\n .expect(\"update current provider\");\n\n let backup = db\n .get_live_backup(\"claude-desktop\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n assert_eq!(\n backup.original_config, \"{}\",\n \"Claude Desktop provider edits should not rewrite takeover backup\"\n );\n\n let profile_path = claude_desktop_profile_path(home.dir.path());\n let profile: Value = read_json_file(&profile_path).expect(\"read desktop profile\");\n assert_eq!(\n profile[\"inferenceGatewayBaseUrl\"],\n json!(\"http://127.0.0.1:15721/claude-desktop\"),\n \"desktop profile should stay pointed at the local gateway during takeover\"\n );\n assert_eq!(profile[\"inferenceGatewayAuthScheme\"], json!(\"bearer\"));\n assert_eq!(\n profile[\"inferenceModels\"],\n json!([{ \"name\": \"claude-sonnet-4-6\", \"labelOverride\": \"DeepSeek V4 Flash Updated\", \"supports1m\": true }]),\n \"provider edits should propagate into the Claude Desktop 3P profile during takeover\"\n );\n }\n\n #[test]\n #[serial]\n fn rename_rejects_missing_original_provider() {\n with_test_home(|state, _| {\n let original = openclaw_provider(\"deepseek\");\n ProviderService::add(state, AppType::OpenClaw, original.clone(), false)\n .expect(\"seed db-only provider\");\n\n let mut renamed = original.clone();\n renamed.id = \"deepseek-copy\".to_string();\n\n let err = ProviderService::update(\n state,\n AppType::OpenClaw,\n Some(\"missing-provider\"),\n renamed,\n )\n .expect_err(\"stale originalId should be rejected\");\n\n assert!(\n err.to_string().contains(\"Original provider\"),\n \"expected missing original provider error, got {err:?}\"\n );\n assert!(\n state\n .db\n .get_provider_by_id(\"deepseek-copy\", AppType::OpenClaw.as_str())\n .expect(\"query renamed provider\")\n .is_none(),\n \"rename must not create a new row when originalId is stale\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn db_only_additive_update_survives_live_config_parse_errors() {\n with_test_home(|state, home| {\n let provider = openclaw_provider(\"deepseek\");\n ProviderService::add(state, AppType::OpenClaw, provider.clone(), false)\n .expect(\"seed db-only provider\");\n\n let stored = state\n .db\n .get_provider_by_id(\"deepseek\", AppType::OpenClaw.as_str())\n .expect(\"query stored provider\")\n .expect(\"provider should exist\");\n assert_eq!(\n stored\n .meta\n .as_ref()\n .and_then(|meta| meta.live_config_managed),\n Some(false),\n \"db-only provider should be marked as not live-managed\"\n );\n\n let openclaw_dir = home.join(\".openclaw\");\n fs::create_dir_all(&openclaw_dir).expect(\"create openclaw dir\");\n fs::write(openclaw_dir.join(\"openclaw.json\"), \"{ invalid json5\")\n .expect(\"write malformed config\");\n\n let mut updated = stored.clone();\n updated.name = \"DeepSeek Edited\".to_string();\n updated.meta.get_or_insert_with(ProviderMeta::default);\n\n ProviderService::update(state, AppType::OpenClaw, None, updated)\n .expect(\"db-only update should ignore live parse errors\");\n\n let saved = state\n .db\n .get_provider_by_id(\"deepseek\", AppType::OpenClaw.as_str())\n .expect(\"query updated provider\")\n .expect(\"updated provider should exist\");\n assert_eq!(saved.name, \"DeepSeek Edited\");\n });\n }\n\n #[test]\n #[serial]\n fn sync_current_provider_for_app_skips_db_only_opencode_provider() {\n with_test_home(|state, _| {\n let provider = opencode_provider(\"db-only-opencode\");\n ProviderService::add(state, AppType::OpenCode, provider.clone(), false)\n .expect(\"seed db-only opencode provider\");\n\n ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)\n .expect(\"sync additive opencode providers\");\n\n let live_providers = crate::opencode_config::get_providers()\n .expect(\"read opencode providers after sync\");\n assert!(\n !live_providers.contains_key(&provider.id),\n \"db-only opencode provider should not be written to live during sync\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn sync_current_provider_for_app_skips_db_only_openclaw_provider() {\n with_test_home(|state, _| {\n let provider = openclaw_provider(\"db-only-openclaw\");\n ProviderService::add(state, AppType::OpenClaw, provider.clone(), false)\n .expect(\"seed db-only openclaw provider\");\n\n ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw)\n .expect(\"sync additive openclaw providers\");\n\n let live_providers = crate::openclaw_config::get_providers()\n .expect(\"read openclaw providers after sync\");\n assert!(\n !live_providers.contains_key(&provider.id),\n \"db-only openclaw provider should not be written to live during sync\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn sync_current_provider_for_app_preserves_legacy_live_opencode_provider() {\n with_test_home(|state, _| {\n let provider = opencode_provider(\"legacy-opencode\");\n crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone())\n .expect(\"seed opencode live provider\");\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .expect(\"seed legacy opencode provider in db\");\n\n let mut updated = provider.clone();\n updated.settings_config[\"options\"][\"apiKey\"] = Value::String(\"updated-key\".to_string());\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &updated)\n .expect(\"update legacy opencode provider in db\");\n\n ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)\n .expect(\"sync legacy opencode provider\");\n\n let live_providers =\n crate::opencode_config::get_providers().expect(\"read opencode providers\");\n assert_eq!(\n live_providers\n .get(&provider.id)\n .and_then(|config| config.get(\"options\"))\n .and_then(|options| options.get(\"apiKey\")),\n Some(&Value::String(\"updated-key\".to_string())),\n \"legacy provider that already exists in live should still be synced\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn sync_current_provider_for_app_restores_legacy_opencode_provider_after_live_reset() {\n with_test_home(|state, _| {\n let provider = opencode_provider(\"legacy-opencode-reset\");\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .expect(\"seed legacy opencode provider in db\");\n\n ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)\n .expect(\"sync legacy opencode provider after reset\");\n\n let live_providers =\n crate::opencode_config::get_providers().expect(\"read opencode providers\");\n assert!(\n live_providers.contains_key(&provider.id),\n \"legacy opencode provider should be restored when live config is reset\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn sync_current_provider_for_app_restores_legacy_openclaw_provider_after_live_reset() {\n with_test_home(|state, _| {\n let mut provider = openclaw_provider(\"legacy-openclaw-reset\");\n provider.settings_config[\"models\"] = json!([\n {\n \"id\": \"claude-sonnet-4\",\n \"name\": \"Claude Sonnet 4\"\n }\n ]);\n state\n .db\n .save_provider(AppType::OpenClaw.as_str(), &provider)\n .expect(\"seed legacy openclaw provider in db\");\n\n ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw)\n .expect(\"sync legacy openclaw provider after reset\");\n\n let live_providers =\n crate::openclaw_config::get_providers().expect(\"read openclaw providers\");\n assert!(\n live_providers.contains_key(&provider.id),\n \"legacy openclaw provider should be restored when live config is reset\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn import_opencode_providers_from_live_marks_provider_as_live_managed() {\n with_test_home(|state, _| {\n let provider = opencode_provider(\"imported-opencode\");\n crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone())\n .expect(\"seed opencode live provider\");\n\n let imported = import_opencode_providers_from_live(state)\n .expect(\"import opencode providers from live\");\n assert_eq!(imported, 1);\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .expect(\"query imported opencode provider\")\n .expect(\"imported opencode provider should exist\");\n assert_eq!(\n saved\n .meta\n .as_ref()\n .and_then(|meta| meta.live_config_managed),\n Some(true),\n \"providers imported from live should be treated as live-managed\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn import_opencode_providers_from_live_updates_existing_provider_from_live() {\n with_test_home(|state, _| {\n let provider = opencode_provider(\"existing-opencode\");\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .expect(\"seed existing opencode provider\");\n\n let mut live_settings = provider.settings_config.clone();\n live_settings.as_object_mut().unwrap().remove(\"name\");\n live_settings[\"npm\"] = Value::String(\"@ai-sdk/anthropic\".to_string());\n live_settings[\"models\"][\"gpt-4o\"][\"name\"] = Value::String(\"Claude Sonnet\".to_string());\n crate::opencode_config::set_provider(&provider.id, live_settings)\n .expect(\"seed edited live opencode provider\");\n\n let updated = import_opencode_providers_from_live(state)\n .expect(\"import opencode providers from live\");\n assert_eq!(updated, 1);\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .expect(\"query updated opencode provider\")\n .expect(\"opencode provider should exist\");\n assert_eq!(saved.name, provider.name);\n assert_eq!(saved.settings_config[\"npm\"], json!(\"@ai-sdk/anthropic\"));\n assert_eq!(\n saved.settings_config[\"models\"][\"gpt-4o\"][\"name\"],\n json!(\"Claude Sonnet\")\n );\n });\n }\n #[test]\n #[serial]\n fn import_openclaw_providers_from_live_marks_provider_as_live_managed() {\n with_test_home(|state, _| {\n let mut provider = openclaw_provider(\"imported-openclaw\");\n provider.settings_config[\"models\"] = json!([\n {\n \"id\": \"claude-sonnet-4\",\n \"name\": \"Claude Sonnet 4\"\n }\n ]);\n crate::openclaw_config::set_provider(&provider.id, provider.settings_config.clone())\n .expect(\"seed openclaw live provider\");\n\n let imported = import_openclaw_providers_from_live(state)\n .expect(\"import openclaw providers from live\");\n assert_eq!(imported, 1);\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenClaw.as_str())\n .expect(\"query imported openclaw provider\")\n .expect(\"imported openclaw provider should exist\");\n assert_eq!(\n saved\n .meta\n .as_ref()\n .and_then(|meta| meta.live_config_managed),\n Some(true),\n \"providers imported from live should be treated as live-managed\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn import_openclaw_providers_from_live_updates_existing_provider_from_live() {\n with_test_home(|state, _| {\n let mut provider = openclaw_provider(\"existing-openclaw\");\n provider.settings_config[\"models\"] = json!([\n {\n \"id\": \"claude-sonnet-4\",\n \"name\": \"Claude Sonnet 4\"\n }\n ]);\n state\n .db\n .save_provider(AppType::OpenClaw.as_str(), &provider)\n .expect(\"seed existing openclaw provider\");\n\n let mut live_settings = provider.settings_config.clone();\n live_settings[\"baseUrl\"] = Value::String(\"https://api.example.com/v1\".to_string());\n live_settings[\"models\"][0][\"name\"] = Value::String(\"Claude Sonnet 4.1\".to_string());\n crate::openclaw_config::set_provider(&provider.id, live_settings)\n .expect(\"seed edited live openclaw provider\");\n\n let updated = import_openclaw_providers_from_live(state)\n .expect(\"import openclaw providers from live\");\n assert_eq!(updated, 1);\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenClaw.as_str())\n .expect(\"query updated openclaw provider\")\n .expect(\"openclaw provider should exist\");\n assert_eq!(saved.name, provider.name);\n assert_eq!(\n saved.settings_config[\"baseUrl\"],\n json!(\"https://api.example.com/v1\")\n );\n assert_eq!(\n saved.settings_config[\"models\"][0][\"name\"],\n json!(\"Claude Sonnet 4.1\")\n );\n });\n }\n\n #[test]\n #[serial]\n fn import_hermes_providers_from_live_updates_existing_provider_from_live() {\n with_test_home(|state, _| {\n let provider = hermes_provider(\"existing-hermes\");\n state\n .db\n .save_provider(AppType::Hermes.as_str(), &provider)\n .expect(\"seed existing hermes provider\");\n\n let mut live_settings = provider.settings_config.clone();\n live_settings[\"base_url\"] = Value::String(\"https://api.hermes.example/v1\".to_string());\n live_settings[\"models\"][\"gpt-4o\"][\"name\"] = Value::String(\"GPT-4o Updated\".to_string());\n crate::hermes_config::set_provider(&provider.id, live_settings)\n .expect(\"seed edited live hermes provider\");\n\n let updated = import_hermes_providers_from_live(state)\n .expect(\"import hermes providers from live\");\n assert_eq!(updated, 1);\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::Hermes.as_str())\n .expect(\"query updated hermes provider\")\n .expect(\"hermes provider should exist\");\n assert_eq!(saved.name, provider.name);\n assert_eq!(\n saved.settings_config[\"base_url\"],\n json!(\"https://api.hermes.example/v1\")\n );\n // models are denormalized from YAML dict to UI-friendly array by\n // get_providers(), so access by index rather than dict key\n assert_eq!(\n saved.settings_config[\"models\"][0][\"name\"],\n json!(\"GPT-4o Updated\")\n );\n assert_eq!(saved.settings_config[\"models\"][0][\"id\"], json!(\"gpt-4o\"));\n });\n }\n\n #[test]\n #[serial]\n fn legacy_additive_provider_still_errors_on_live_config_parse_failure() {\n with_test_home(|state, home| {\n let provider = openclaw_provider(\"legacy-provider\");\n state\n .db\n .save_provider(AppType::OpenClaw.as_str(), &provider)\n .expect(\"seed legacy provider without live_config_managed marker\");\n\n let openclaw_dir = home.join(\".openclaw\");\n fs::create_dir_all(&openclaw_dir).expect(\"create openclaw dir\");\n fs::write(openclaw_dir.join(\"openclaw.json\"), \"{ invalid json5\")\n .expect(\"write malformed config\");\n\n let mut updated = provider.clone();\n updated.name = \"Legacy Edited\".to_string();\n\n let err = ProviderService::update(state, AppType::OpenClaw, None, updated)\n .expect_err(\"legacy providers should still surface live parse errors\");\n assert!(\n err.to_string().contains(\"Failed to parse OpenClaw config\"),\n \"expected parse error, got {err:?}\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn update_persists_non_current_omo_variants_in_database() {\n with_test_home(|state, _| {\n for category in [\"omo\", \"omo-slim\"] {\n let provider = opencode_omo_provider(&format!(\"{category}-provider\"), category);\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .unwrap_or_else(|err| panic!(\"seed {category} provider: {err}\"));\n\n let mut updated = provider.clone();\n updated.name = format!(\"Updated {category}\");\n updated.settings_config[\"agents\"][\"writer\"][\"model\"] =\n Value::String(format!(\"{category}-next-model\"));\n\n ProviderService::update(state, AppType::OpenCode, None, updated)\n .unwrap_or_else(|err| panic!(\"update {category} provider: {err}\"));\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .unwrap_or_else(|err| panic!(\"query updated {category} provider: {err}\"))\n .unwrap_or_else(|| panic!(\"{category} provider should exist\"));\n\n assert_eq!(saved.name, format!(\"Updated {category}\"));\n assert_eq!(\n saved.settings_config[\"agents\"][\"writer\"][\"model\"],\n Value::String(format!(\"{category}-next-model\")),\n \"{category} updates should persist in the database\"\n );\n }\n });\n }\n\n #[test]\n #[serial]\n fn update_current_omo_variant_rewrites_config_from_saved_provider() {\n with_test_home(|state, home| {\n for category in [\"omo\", \"omo-slim\"] {\n let provider = opencode_omo_provider(&format!(\"{category}-current\"), category);\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .unwrap_or_else(|err| panic!(\"seed current {category} provider: {err}\"));\n state\n .db\n .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, category)\n .unwrap_or_else(|err| panic!(\"set current {category} provider: {err}\"));\n\n let mut updated = provider.clone();\n updated.name = format!(\"Current {category} updated\");\n updated.settings_config[\"agents\"][\"writer\"][\"model\"] =\n Value::String(format!(\"{category}-saved-model\"));\n updated.settings_config[\"otherFields\"][\"theme\"] =\n Value::String(format!(\"{category}-light\"));\n\n ProviderService::update(state, AppType::OpenCode, None, updated)\n .unwrap_or_else(|err| panic!(\"update current {category} provider: {err}\"));\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .unwrap_or_else(|err| panic!(\"query current {category} provider: {err}\"))\n .unwrap_or_else(|| panic!(\"current {category} provider should exist\"));\n assert_eq!(saved.name, format!(\"Current {category} updated\"));\n\n let written = fs::read_to_string(omo_config_path(home, category))\n .unwrap_or_else(|err| panic!(\"read written {category} config: {err}\"));\n let written_json: Value = serde_json::from_str(&written)\n .unwrap_or_else(|err| panic!(\"parse written {category} config: {err}\"));\n\n assert_eq!(\n written_json[\"agents\"][\"writer\"][\"model\"],\n Value::String(format!(\"{category}-saved-model\")),\n \"{category} config should be written from the saved provider state\"\n );\n assert_eq!(\n written_json[\"theme\"],\n Value::String(format!(\"{category}-light\")),\n \"{category} top-level config should reflect updated otherFields\"\n );\n }\n });\n }\n\n #[test]\n #[serial]\n fn update_current_omo_variant_does_not_persist_database_when_file_write_fails() {\n with_test_home(|state, home| {\n let provider = opencode_omo_provider(\"omo-current\", \"omo\");\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .unwrap_or_else(|err| panic!(\"seed current omo provider: {err}\"));\n state\n .db\n .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, \"omo\")\n .unwrap_or_else(|err| panic!(\"set current omo provider: {err}\"));\n\n let config_dir = home.join(\".config\").join(\"opencode\");\n fs::create_dir_all(config_dir.parent().expect(\"config dir parent\"))\n .expect(\"create .config dir\");\n fs::write(&config_dir, \"not a directory\").expect(\"block opencode config dir\");\n\n let mut updated = provider.clone();\n updated.name = \"Current omo updated\".to_string();\n updated.settings_config[\"agents\"][\"writer\"][\"model\"] =\n Value::String(\"omo-saved-model\".to_string());\n\n ProviderService::update(state, AppType::OpenCode, None, updated)\n .expect_err(\"update should fail when current omo file write fails\");\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .unwrap_or_else(|err| panic!(\"query current omo provider: {err}\"))\n .unwrap_or_else(|| panic!(\"current omo provider should exist\"));\n\n assert_eq!(saved.name, provider.name);\n assert_eq!(\n saved.settings_config[\"agents\"][\"writer\"][\"model\"],\n provider.settings_config[\"agents\"][\"writer\"][\"model\"],\n \"database should remain unchanged when file write fails\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn update_current_omo_variant_rolls_back_file_when_plugin_sync_fails() {\n with_test_home(|state, home| {\n let provider = opencode_omo_provider(\"omo-current\", \"omo\");\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .unwrap_or_else(|err| panic!(\"seed current omo provider: {err}\"));\n state\n .db\n .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, \"omo\")\n .unwrap_or_else(|err| panic!(\"set current omo provider: {err}\"));\n\n let config_path = omo_config_path(home, \"omo\");\n fs::create_dir_all(config_path.parent().expect(\"omo config parent\"))\n .expect(\"create omo config dir\");\n let previous_content = serde_json::to_string_pretty(&json!({\n \"theme\": \"legacy-live-theme\",\n \"agents\": {\n \"writer\": {\n \"model\": \"legacy-live-model\"\n }\n },\n \"categories\": {\n \"default\": [\"writer\"]\n }\n }))\n .expect(\"serialize previous config\");\n fs::write(&config_path, &previous_content).expect(\"seed previous omo config\");\n\n let opencode_config_path = home.join(\".config\").join(\"opencode\").join(\"opencode.json\");\n fs::write(&opencode_config_path, \"{ invalid json\").expect(\"seed malformed opencode\");\n\n let mut updated = provider.clone();\n updated.name = \"Current omo updated\".to_string();\n updated.settings_config[\"agents\"][\"writer\"][\"model\"] =\n Value::String(\"omo-saved-model\".to_string());\n updated.settings_config[\"otherFields\"][\"theme\"] =\n Value::String(\"omo-light\".to_string());\n\n ProviderService::update(state, AppType::OpenCode, None, updated)\n .expect_err(\"update should fail when plugin sync fails\");\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .unwrap_or_else(|err| panic!(\"query current omo provider: {err}\"))\n .unwrap_or_else(|| panic!(\"current omo provider should exist\"));\n\n assert_eq!(saved.name, provider.name);\n assert_eq!(\n saved.settings_config[\"agents\"][\"writer\"][\"model\"],\n provider.settings_config[\"agents\"][\"writer\"][\"model\"],\n \"database should remain unchanged when plugin sync fails\"\n );\n\n let written =\n fs::read_to_string(&config_path).expect(\"read rolled back omo config content\");\n assert_eq!(\n written, previous_content,\n \"OMO config should roll back to its previous on-disk contents\"\n );\n });\n }\n}\n\nimpl ProviderService {\n fn normalize_provider_if_claude(app_type: &AppType, provider: &mut Provider) {\n if matches!(app_type, AppType::Claude) {\n let mut v = provider.settings_config.clone();\n if normalize_claude_models_in_value(&mut v) {\n provider.settings_config = v;\n }\n }\n }\n\n /// Check whether a provider exists in live config, tolerating parse errors\n /// only for providers that are explicitly marked as DB-only.\n fn check_live_config_exists(\n app_type: &AppType,\n provider_id: &str,\n live_config_managed: Option<bool>,\n ) -> Result<bool, AppError> {\n if live_config_managed == Some(false) {\n Ok(provider_exists_in_live_config(app_type, provider_id).unwrap_or(false))\n } else {\n provider_exists_in_live_config(app_type, provider_id)\n }\n }\n\n fn provider_live_config_managed(provider: &Provider) -> Option<bool> {\n provider\n .meta\n .as_ref()\n .and_then(|meta| meta.live_config_managed)\n }\n\n fn set_provider_live_config_managed(provider: &mut Provider, managed: bool) {\n provider\n .meta\n .get_or_insert_with(Default::default)\n .live_config_managed = Some(managed);\n }\n\n fn normalize_usage_script_credential_overrides(app_type: &AppType, provider: &mut Provider) {\n let current_credentials = provider.resolve_usage_credentials(app_type);\n\n let Some(usage_script) = provider\n .meta\n .as_mut()\n .and_then(|meta| meta.usage_script.as_mut())\n else {\n return;\n };\n\n if usage_script.template_type.as_deref() == Some(\"token_plan\") {\n return;\n }\n\n if usage_script.api_key.as_deref().is_some_and(|api_key| {\n Self::should_clear_usage_api_key_override(api_key, &current_credentials)\n }) {\n usage_script.api_key = None;\n }\n\n if usage_script.base_url.as_deref().is_some_and(|base_url| {\n Self::should_clear_usage_base_url_override(base_url, &current_credentials)\n }) {\n usage_script.base_url = None;\n }\n }\n\n fn should_clear_usage_api_key_override(\n script_api_key: &str,\n current_credentials: &(String, String),\n ) -> bool {\n let candidate = script_api_key.trim();\n if candidate.is_empty() {\n return true;\n }\n\n let matches_provider_key = |api_key: &str| {\n let api_key = api_key.trim();\n !api_key.is_empty() && api_key == candidate\n };\n\n matches_provider_key(&current_credentials.1)\n }\n\n fn should_clear_usage_base_url_override(\n script_base_url: &str,\n current_credentials: &(String, String),\n ) -> bool {\n let candidate = Self::normalize_usage_base_url_for_compare(script_base_url);\n if candidate.is_empty() {\n return true;\n }\n\n let matches_provider_base_url = |base_url: &str| {\n let base_url = Self::normalize_usage_base_url_for_compare(base_url);\n !base_url.is_empty() && base_url == candidate\n };\n\n matches_provider_base_url(&current_credentials.0)\n }\n\n fn normalize_usage_base_url_for_compare(base_url: &str) -> String {\n base_url.trim().trim_end_matches('/').to_string()\n }\n\n /// List all providers for an app type\n pub fn list(\n state: &AppState,\n app_type: AppType,\n ) -> Result<IndexMap<String, Provider>, AppError> {\n state.db.get_all_providers(app_type.as_str())\n }\n\n /// Get current provider ID\n ///\n /// 使用有效的当前供应商 ID(验证过存在性)。\n /// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。\n /// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。\n ///\n /// 对于累加模式应用(OpenCode, OpenClaw),不存在\"当前供应商\"概念,直接返回空字符串。\n pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {\n // Additive mode apps have no \"current\" provider concept\n if app_type.is_additive_mode() {\n return Ok(String::new());\n }\n crate::settings::get_effective_current_provider(&state.db, &app_type)\n .map(|opt| opt.unwrap_or_default())\n }\n\n /// Add a new provider\n pub fn add(\n state: &AppState,\n app_type: AppType,\n provider: Provider,\n add_to_live: bool,\n ) -> Result<bool, AppError> {\n let mut provider = provider;\n // Normalize Claude model keys\n Self::normalize_provider_if_claude(&app_type, &mut provider);\n Self::validate_provider_settings(&app_type, &provider)?;\n normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;\n Self::normalize_usage_script_credential_overrides(&app_type, &mut provider);\n if app_type.is_additive_mode() {\n Self::set_provider_live_config_managed(&mut provider, add_to_live);\n }\n\n // Save to database\n state.db.save_provider(app_type.as_str(), &provider)?;\n\n // Additive mode apps (OpenCode, OpenClaw): optionally write to live config.\n if app_type.is_additive_mode() {\n // OMO / OMO Slim providers use exclusive mode and write to dedicated config file.\n if matches!(app_type, AppType::OpenCode)\n && matches!(provider.category.as_deref(), Some(\"omo\") | Some(\"omo-slim\"))\n {\n // Do not auto-enable newly added OMO / OMO Slim providers.\n // Users must explicitly switch/apply an OMO provider to activate it.\n return Ok(true);\n }\n if !add_to_live {\n return Ok(true);\n }\n write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n return Ok(true);\n }\n\n // For other apps: Check if sync is needed (if this is current provider, or no current provider)\n let current = state.db.get_current_provider(app_type.as_str())?;\n if current.is_none() {\n // No current provider, set as current and sync\n state\n .db\n .set_current_provider(app_type.as_str(), &provider.id)?;\n write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n }\n\n Ok(true)\n }\n\n /// Update a provider\n pub fn update(\n state: &AppState,\n app_type: AppType,\n original_id: Option<&str>,\n provider: Provider,\n ) -> Result<bool, AppError> {\n let mut provider = provider;\n let original_id = original_id.unwrap_or(provider.id.as_str()).to_string();\n let provider_id_changed = original_id != provider.id;\n let existing_provider = state\n .db\n .get_provider_by_id(&original_id, app_type.as_str())?;\n // Normalize Claude model keys\n Self::normalize_provider_if_claude(&app_type, &mut provider);\n Self::validate_provider_settings(&app_type, &provider)?;\n normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;\n Self::normalize_usage_script_credential_overrides(&app_type, &mut provider);\n\n if provider_id_changed {\n if !app_type.is_additive_mode() {\n return Err(AppError::Message(\n \"Only additive-mode providers support changing provider key\".to_string(),\n ));\n }\n\n let Some(existing_provider) = existing_provider else {\n return Err(AppError::Message(format!(\n \"Original provider '{}' does not exist in app '{}'\",\n original_id,\n app_type.as_str()\n )));\n };\n\n // OMO / OMO Slim providers are activated via a dedicated current-state mechanism\n // (set_omo_provider_current) that is NOT captured by provider_exists_in_live_config,\n // which only checks opencode.json. A rename would orphan that current-state marker\n // and silently break subsequent OMO file syncs. Block it unconditionally.\n if matches!(app_type, AppType::OpenCode)\n && matches!(\n existing_provider.category.as_deref(),\n Some(\"omo\") | Some(\"omo-slim\")\n )\n {\n return Err(AppError::Message(\n \"Provider key cannot be changed for OMO/OMO Slim providers\".to_string(),\n ));\n }\n\n let original_in_live = Self::check_live_config_exists(\n &app_type,\n &original_id,\n Self::provider_live_config_managed(&existing_provider),\n )?;\n if original_in_live {\n return Err(AppError::Message(\n \"Provider key cannot be changed after the provider has been added to the app config\"\n .to_string(),\n ));\n }\n\n let next_id_in_live = Self::check_live_config_exists(\n &app_type,\n &provider.id,\n Self::provider_live_config_managed(&existing_provider),\n )?;\n if state\n .db\n .get_provider_by_id(&provider.id, app_type.as_str())?\n .is_some()\n || next_id_in_live\n {\n return Err(AppError::Message(format!(\n \"Provider '{}' already exists in app '{}'\",\n provider.id,\n app_type.as_str()\n )));\n }\n\n Self::set_provider_live_config_managed(&mut provider, false);\n state.db.save_provider(app_type.as_str(), &provider)?;\n state.db.delete_provider(app_type.as_str(), &original_id)?;\n\n if crate::settings::get_current_provider(&app_type).as_deref() == Some(&original_id) {\n crate::settings::set_current_provider(&app_type, Some(provider.id.as_str()))?;\n }\n\n return Ok(true);\n }\n\n // Additive mode apps (OpenCode, OpenClaw): only sync to live when the provider\n // already exists in live config. Editing a DB-only provider must not auto-add it.\n if app_type.is_additive_mode() {\n let omo_variant = if matches!(app_type, AppType::OpenCode) {\n match provider.category.as_deref() {\n Some(\"omo\") => Some(&crate::services::omo::STANDARD),\n Some(\"omo-slim\") => Some(&crate::services::omo::SLIM),\n _ => None,\n }\n } else {\n None\n };\n if let Some(variant) = omo_variant {\n let is_current = state.db.is_omo_provider_current(\n app_type.as_str(),\n &provider.id,\n variant.category,\n )?;\n if is_current {\n crate::services::OmoService::write_provider_config_to_file(&provider, variant)?;\n }\n if let Err(err) = state.db.save_provider(app_type.as_str(), &provider) {\n if is_current {\n if let Err(rollback_err) =\n crate::services::OmoService::write_config_to_file(state, variant)\n {\n log::warn!(\n \"Failed to roll back {} config after DB save error: {}\",\n variant.label,\n rollback_err\n );\n }\n }\n return Err(err);\n }\n return Ok(true);\n }\n let live_config_managed = Self::check_live_config_exists(\n &app_type,\n &provider.id,\n Self::provider_live_config_managed(&provider).or_else(|| {\n existing_provider\n .as_ref()\n .and_then(Self::provider_live_config_managed)\n }),\n )?;\n Self::set_provider_live_config_managed(&mut provider, live_config_managed);\n\n // Save to database after live-config presence is resolved so parse errors\n // do not report failure after already mutating DB state.\n state.db.save_provider(app_type.as_str(), &provider)?;\n\n if !live_config_managed {\n return Ok(true);\n }\n write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n return Ok(true);\n }\n\n // Save to database\n state.db.save_provider(app_type.as_str(), &provider)?;\n\n // For other apps: Check if this is current provider (use effective current, not just DB)\n let effective_current =\n crate::settings::get_effective_current_provider(&state.db, &app_type)?;\n let is_current = effective_current.as_deref() == Some(provider.id.as_str());\n\n if is_current {\n // 如果 Claude 代理接管处于激活状态,并且代理服务正在运行:\n // - 不直接走普通 Live 写入逻辑\n // - 改为更新 Live 备份,并在 Claude 下同步代理安全的 Live 配置\n let has_live_backup =\n futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))\n .ok()\n .flatten()\n .is_some();\n let live_taken_over = state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&app_type);\n // Backup or live placeholders mean the live file is currently owned\n // by proxy takeover, including the short activation window before\n // proxy_config.enabled is committed.\n let should_sync_via_proxy = has_live_backup || live_taken_over;\n\n if should_sync_via_proxy {\n if matches!(app_type, AppType::ClaudeDesktop) {\n write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n } else {\n futures::executor::block_on(\n state\n .proxy_service\n .update_live_backup_from_provider(app_type.as_str(), &provider),\n )\n .map_err(|e| AppError::Message(format!(\"更新 Live 备份失败: {e}\")))?;\n }\n\n if futures::executor::block_on(state.proxy_service.is_running()) {\n if matches!(app_type, AppType::Claude) {\n futures::executor::block_on(\n state\n .proxy_service\n .sync_claude_live_from_provider_while_proxy_active(&provider),\n )\n .map_err(|e| {\n AppError::Message(format!(\"同步 Claude Live 配置失败: {e}\"))\n })?;\n } else if live_taken_over && matches!(app_type, AppType::Codex) {\n // Codex model mappings are projected into a generated\n // model_catalog_json file. Refresh takeover-owned Live\n // immediately so adding/removing mappings cannot leave\n // the previous catalog pointer and capabilities active.\n futures::executor::block_on(\n state\n .proxy_service\n .sync_codex_live_from_provider_while_proxy_active(&provider),\n )\n .map_err(|e| AppError::Message(format!(\"同步 Codex Live 配置失败: {e}\")))?;\n }\n }\n } else {\n write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n // 重写 live 后只重投影本应用的 MCP:全量 sync_all_enabled 会把\n // 无关应用的 live 损坏(如 ~/.claude.json 坏 JSON)牵连进保存\n // 流程。走到这里 DB 与 live 都已按新配置落盘,保存事实上已\n // 成功;投影失败降级为警告,避免制造\"保存失败\"假象(MCP\n // 投影可自愈:下次切换 / 任一 MCP 启停都会重新投影)。\n if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) {\n log::warn!(\n \"保存供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}\"\n );\n }\n }\n }\n\n Ok(true)\n }\n\n /// Delete a provider\n ///\n /// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。\n /// 对于累加模式应用(OpenCode, OpenClaw),可以随时删除任意供应商,同时从 live 配置中移除。\n pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {\n // Additive mode apps - no current provider concept\n if app_type.is_additive_mode() {\n // Single DB read shared across all additive-mode sub-paths below.\n let existing = state.db.get_provider_by_id(id, app_type.as_str())?;\n\n if matches!(app_type, AppType::OpenCode) {\n let provider_category = existing.as_ref().and_then(|p| p.category.clone());\n let omo_variant = match provider_category.as_deref() {\n Some(\"omo\") => Some(&crate::services::omo::STANDARD),\n Some(\"omo-slim\") => Some(&crate::services::omo::SLIM),\n _ => None,\n };\n if let Some(variant) = omo_variant {\n let was_current = state.db.is_omo_provider_current(\n app_type.as_str(),\n id,\n variant.category,\n )?;\n state.db.delete_provider(app_type.as_str(), id)?;\n if was_current {\n crate::services::OmoService::delete_config_file(variant)?;\n }\n return Ok(());\n }\n }\n\n // Non-OMO path for both OpenCode and OpenClaw:\n // remove from live first (atomicity), then DB.\n //\n // Use check_live_config_exists rather than trusting the flag alone: the flag\n // can be stale (Some(false) for a provider that was written to live before the\n // live_config_managed flip was introduced). check_live_config_exists reads the\n // actual file when the flag is Some(false), so it handles historical data correctly.\n let live_managed = existing\n .as_ref()\n .and_then(Self::provider_live_config_managed);\n if Self::check_live_config_exists(&app_type, id, live_managed)? {\n match app_type {\n AppType::OpenCode => remove_opencode_provider_from_live(id)?,\n AppType::OpenClaw => remove_openclaw_provider_from_live(id)?,\n AppType::Hermes => remove_hermes_provider_from_live(id)?,\n _ => {}\n }\n }\n state.db.delete_provider(app_type.as_str(), id)?;\n return Ok(());\n }\n\n // For other apps: Check both local settings and database\n let local_current = crate::settings::get_current_provider(&app_type);\n let db_current = state.db.get_current_provider(app_type.as_str())?;\n\n if local_current.as_deref() == Some(id) || db_current.as_deref() == Some(id) {\n return Err(AppError::Message(\n \"无法删除当前正在使用的供应商\".to_string(),\n ));\n }\n\n state.db.delete_provider(app_type.as_str(), id)\n }\n\n /// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw)\n ///\n /// Does NOT delete from database - provider remains in the list.\n /// This is used when user wants to \"remove\" a provider from active config\n /// but keep it available for future use.\n pub fn remove_from_live_config(\n state: &AppState,\n app_type: AppType,\n id: &str,\n ) -> Result<(), AppError> {\n match app_type {\n AppType::OpenCode => {\n let provider_category = state\n .db\n .get_provider_by_id(id, app_type.as_str())?\n .and_then(|p| p.category);\n\n let omo_variant = match provider_category.as_deref() {\n Some(\"omo\") => Some(&crate::services::omo::STANDARD),\n Some(\"omo-slim\") => Some(&crate::services::omo::SLIM),\n _ => None,\n };\n if let Some(variant) = omo_variant {\n state\n .db\n .clear_omo_provider_current(app_type.as_str(), id, variant.category)?;\n let still_has_current = state\n .db\n .get_current_omo_provider(\"opencode\", variant.category)?\n .is_some();\n if still_has_current {\n crate::services::OmoService::write_config_to_file(state, variant)?;\n } else {\n crate::services::OmoService::delete_config_file(variant)?;\n }\n } else {\n remove_opencode_provider_from_live(id)?;\n }\n }\n AppType::OpenClaw => {\n remove_openclaw_provider_from_live(id)?;\n }\n AppType::Hermes => {\n remove_hermes_provider_from_live(id)?;\n }\n _ => {\n return Err(AppError::Message(format!(\n \"App {} does not support remove from live config\",\n app_type.as_str()\n )));\n }\n }\n\n if let Some(mut provider) = state.db.get_provider_by_id(id, app_type.as_str())? {\n Self::set_provider_live_config_managed(&mut provider, false);\n state.db.save_provider(app_type.as_str(), &provider)?;\n }\n\n Ok(())\n }\n\n /// Switch to a provider\n ///\n /// Switch flow:\n /// 1. Validate target provider exists\n /// 2. Check if proxy takeover mode is active AND proxy server is running\n /// 3. If takeover mode active: hot-switch proxy target and refresh proxy-safe Live labels\n /// 4. If normal mode:\n /// a. **Backfill mechanism**: Backfill current live config to current provider\n /// b. Update local settings current_provider_xxx (device-level)\n /// c. Update database is_current (as default for new devices)\n /// d. Write target provider config to live files\n /// e. Sync MCP configuration\n pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<SwitchResult, AppError> {\n // Check if provider exists\n let providers = state.db.get_all_providers(app_type.as_str())?;\n let _provider = providers\n .get(id)\n .ok_or_else(|| AppError::Message(format!(\"供应商 {id} 不存在\")))?;\n\n // OMO providers are switched through their own exclusive path.\n if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some(\"omo\") {\n return Self::switch_normal(state, app_type, id, &providers);\n }\n\n // OMO Slim providers are switched through their own exclusive path.\n if matches!(app_type, AppType::OpenCode)\n && _provider.category.as_deref() == Some(\"omo-slim\")\n {\n return Self::switch_normal(state, app_type, id, &providers);\n }\n\n if matches!(app_type, AppType::ClaudeDesktop) {\n return Self::switch_normal(state, app_type, id, &providers);\n }\n\n // Provider switches and takeover toggles both mutate live config and the\n // restore backup. Serialize them per app, then decide from the locked\n // current state so a just-started takeover cannot be overwritten by a\n // normal live write.\n let _switch_guard =\n if matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) {\n Some(futures::executor::block_on(\n state.proxy_service.lock_switch_for_app(app_type.as_str()),\n ))\n } else {\n None\n };\n\n // Backup or live placeholders mean the live file is owned by proxy\n // takeover, even if the proxy server is temporarily stopped or is in the\n // activation window before enabled=true is committed.\n let is_app_taken_over =\n futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))\n .ok()\n .flatten()\n .is_some();\n let live_taken_over = state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&app_type);\n\n let should_hot_switch = is_app_taken_over || live_taken_over;\n\n // Block switching to official providers when proxy takeover is active.\n // Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans.\n if should_hot_switch\n && _provider.category.as_deref() == Some(\"official\")\n && !official_provider_supports_proxy_takeover(&app_type, _provider)\n {\n return Err(AppError::localized(\n \"switch.official_blocked_by_proxy\",\n \"代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。\",\n \"Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.\",\n ));\n }\n\n if should_hot_switch {\n // Proxy takeover mode: hot-switch without restoring upstream Live config.\n // The proxy layer may still refresh proxy-safe Live fields so client labels\n // follow the selected provider while endpoints remain local.\n log::info!(\n \"代理接管模式:热切换 {} 的目标供应商为 {}\",\n app_type.as_str(),\n id\n );\n\n futures::executor::block_on(\n state\n .proxy_service\n .hot_switch_provider_inner(app_type.as_str(), id),\n )\n .map_err(|e| AppError::Message(format!(\"热切换失败: {e}\")))?;\n\n // The proxy server will route requests to the new provider via is_current.\n // MCP sync is intentionally skipped while Live config is owned by takeover.\n return Ok(SwitchResult::default());\n }\n\n // Normal mode: full switch with Live config write\n Self::switch_normal(state, app_type, id, &providers)\n }\n\n /// Normal switch flow (non-proxy mode)\n fn switch_normal(\n state: &AppState,\n app_type: AppType,\n id: &str,\n providers: &indexmap::IndexMap<String, Provider>,\n ) -> Result<SwitchResult, AppError> {\n let provider = providers\n .get(id)\n .ok_or_else(|| AppError::Message(format!(\"供应商 {id} 不存在\")))?;\n\n // OMO ↔ OMO Slim are mutually exclusive; activating one removes the other's config file.\n if matches!(app_type, AppType::OpenCode) {\n let omo_pair = match provider.category.as_deref() {\n Some(\"omo\") => Some((&crate::services::omo::STANDARD, &crate::services::omo::SLIM)),\n Some(\"omo-slim\") => {\n Some((&crate::services::omo::SLIM, &crate::services::omo::STANDARD))\n }\n _ => None,\n };\n if let Some((enable, disable)) = omo_pair {\n state\n .db\n .set_omo_provider_current(app_type.as_str(), id, enable.category)?;\n crate::services::OmoService::write_config_to_file(state, enable)?;\n let _ = crate::services::OmoService::delete_config_file(disable);\n return Ok(SwitchResult::default());\n }\n }\n\n let mut result = SwitchResult::default();\n\n // Backfill: Backfill current live config to current provider\n // Use effective current provider (validated existence) to ensure backfill targets valid provider\n let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;\n\n if let Some(current_id) = current_id {\n if current_id != id {\n // Additive mode apps - all providers coexist in the same file,\n // no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)\n if !app_type.is_additive_mode() {\n // Only backfill when switching to a different provider\n if let Ok(live_config) = read_live_settings(app_type.clone()) {\n if let Some(mut current_provider) = providers.get(&current_id).cloned() {\n // 切走前先把 live 里的可共享改动(含用户直接在应用内\n // 装插件/加 hook/改偏好)同步进通用配置片段,再做剥离回填。\n // 详见 sync_common_config_snippet_from_live 的文档。\n Self::sync_common_config_snippet_from_live(\n state,\n &app_type,\n &current_provider,\n &live_config,\n &mut result,\n );\n\n current_provider.settings_config =\n strip_common_config_from_live_settings(\n state.db.as_ref(),\n &app_type,\n &current_provider,\n live_config,\n );\n if let Err(e) =\n state.db.save_provider(app_type.as_str(), &current_provider)\n {\n log::warn!(\"Backfill failed: {e}\");\n result\n .warnings\n .push(format!(\"backfill_failed:{current_id}\"));\n }\n }\n }\n }\n }\n }\n\n // Additive mode apps skip setting is_current (no such concept)\n if !app_type.is_additive_mode() {\n // Update local settings (device-level, takes priority)\n crate::settings::set_current_provider(&app_type, Some(id))?;\n\n // Update database is_current (as default for new devices)\n state.db.set_current_provider(app_type.as_str(), id)?;\n }\n\n // Sync to live (write_gemini_live handles security flag internally for Gemini)\n write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;\n\n // Hermes is additive, so \"switching\" doesn't overwrite a live config file\n // — we instead update the top-level `model:` section to point at this\n // provider's first declared model. Without this, clicking \"switch\" would\n // only shuffle entries in custom_providers[] while Hermes keeps using\n // whatever `model.provider` was set before.\n if matches!(app_type, AppType::Hermes) {\n if let Err(e) =\n crate::hermes_config::apply_switch_defaults(&provider.id, &provider.settings_config)\n {\n log::warn!(\n \"Failed to update Hermes model defaults after switching to '{}': {e}\",\n provider.id\n );\n result\n .warnings\n .push(format!(\"hermes_model_defaults_failed:{}\", provider.id));\n }\n }\n\n // For additive-mode providers that were DB-only (live_config_managed == Some(false)),\n // flip the flag to true now that the provider has been successfully written to the live\n // file. This ensures sync_all_providers_to_live() will include it on future syncs.\n //\n // If persisting the marker fails, roll back the just-written live config so we don't leave\n // the provider in a silent inconsistent state (present in live, but still marked DB-only).\n if app_type.is_additive_mode() && Self::provider_live_config_managed(provider) != Some(true)\n {\n let mut updated = provider.clone();\n Self::set_provider_live_config_managed(&mut updated, true);\n if let Err(e) = state.db.save_provider(app_type.as_str(), &updated) {\n let rollback_result = match app_type {\n AppType::OpenCode => remove_opencode_provider_from_live(&provider.id),\n AppType::OpenClaw => remove_openclaw_provider_from_live(&provider.id),\n AppType::Hermes => remove_hermes_provider_from_live(&provider.id),\n _ => Ok(()),\n };\n\n match rollback_result {\n Ok(()) => {\n return Err(AppError::Message(format!(\n \"Failed to persist live_config_managed for '{}' after writing live config; live changes were rolled back: {e}\",\n provider.id\n )));\n }\n Err(rollback_err) => {\n return Err(AppError::Message(format!(\n \"Failed to persist live_config_managed for '{}' after writing live config: {e}; additionally failed to roll back live config: {rollback_err}\",\n provider.id\n )));\n }\n }\n }\n }\n\n // 切换重写了目标应用的 live,只重投影该应用的 MCP(Codex 的\n // [mcp_servers] 与 live 同文件,整体替换后必须补回;其余应用的\n // MCP 文件独立于 live,投影是幂等维护)。不用全量 sync_all_enabled:\n // 无关应用的 live 损坏(如 ~/.claude.json 坏 JSON)不该阻断切换。\n // 走到这里 DB is_current 与 live 都已落盘,切换事实上已成功;\n // 投影失败上抛会让前端报\"切换失败\"制造分裂假象,故降级为警告\n // (MCP 投影可自愈:下次切换 / 任一 MCP 启停都会重新投影)。\n if let Err(err) = McpService::sync_enabled_for_app(state, &app_type) {\n log::warn!(\"切换供应商后重投影 {app_type:?} MCP 失败(将在下次同步时自愈): {err}\");\n }\n\n Ok(result)\n }\n\n /// Sync current provider to live configuration (re-export)\n pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {\n sync_current_to_live(state)\n }\n\n pub fn sync_current_provider_for_app(\n state: &AppState,\n app_type: AppType,\n ) -> Result<(), AppError> {\n if app_type.is_additive_mode() {\n return sync_current_provider_for_app_to_live(state, &app_type);\n }\n\n let current_id =\n match crate::settings::get_effective_current_provider(&state.db, &app_type)? {\n Some(id) => id,\n None => return Ok(()),\n };\n\n let providers = state.db.get_all_providers(app_type.as_str())?;\n let Some(provider) = providers.get(&current_id) else {\n return Ok(());\n };\n\n let has_live_backup =\n futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))\n .ok()\n .flatten()\n .is_some();\n\n let live_taken_over = state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&app_type);\n\n // See the save path above: backup/placeholders are the ownership signal\n // here, not just proxy_config.enabled.\n if has_live_backup || live_taken_over {\n if matches!(app_type, AppType::ClaudeDesktop) {\n write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;\n return Ok(());\n }\n\n futures::executor::block_on(\n state\n .proxy_service\n .update_live_backup_from_provider(app_type.as_str(), provider),\n )\n .map_err(|e| AppError::Message(format!(\"更新 Live 备份失败: {e}\")))?;\n return Ok(());\n }\n\n sync_current_provider_for_app_to_live(state, &app_type)\n }\n\n pub fn migrate_legacy_common_config_usage(\n state: &AppState,\n app_type: AppType,\n legacy_snippet: &str,\n ) -> Result<(), AppError> {\n if app_type.is_additive_mode() || legacy_snippet.trim().is_empty() {\n return Ok(());\n }\n\n let providers = state.db.get_all_providers(app_type.as_str())?;\n\n for provider in providers.values() {\n if provider\n .meta\n .as_ref()\n .and_then(|meta| meta.common_config_enabled)\n .is_some()\n {\n continue;\n }\n\n if !live::provider_uses_common_config(&app_type, provider, Some(legacy_snippet)) {\n continue;\n }\n\n let mut updated_provider = provider.clone();\n updated_provider\n .meta\n .get_or_insert_with(Default::default)\n .common_config_enabled = Some(true);\n\n match live::remove_common_config_from_settings(\n &app_type,\n &updated_provider.settings_config,\n legacy_snippet,\n ) {\n Ok(settings) => updated_provider.settings_config = settings,\n Err(err) => {\n log::warn!(\n \"Failed to normalize legacy common config for {} provider '{}': {err}\",\n app_type.as_str(),\n updated_provider.id\n );\n }\n }\n\n state\n .db\n .save_provider(app_type.as_str(), &updated_provider)?;\n }\n\n Ok(())\n }\n\n pub fn migrate_legacy_common_config_usage_if_needed(\n state: &AppState,\n app_type: AppType,\n ) -> Result<(), AppError> {\n if app_type.is_additive_mode() {\n return Ok(());\n }\n\n let Some(snippet) = state.db.get_config_snippet(app_type.as_str())? else {\n return Ok(());\n };\n\n if snippet.trim().is_empty() {\n return Ok(());\n }\n\n Self::migrate_legacy_common_config_usage(state, app_type, &snippet)\n }\n\n /// 切走某供应商前,把它 live 配置里的可共享部分重新提取并**整体替换**到\n /// 通用配置片段,使在 live 应用里直接做的改动不会因切换而丢失。\n ///\n /// 采用\"整体重提取 + 替换\"而非\"只合并新增\",是为了同时覆盖三种情况:\n /// - **新增**:用户直接在应用里装了插件、加了 hook、改了 env/主题/权限等共享\n /// 偏好,被捕获进通用配置,切到别的供应商也带得过去;\n /// - **删除**:被删掉的键不在新提取结果里,于是从片段里消失、下次切换不会被\n /// 重新注入——否则会出现\"插件怎么删也删不掉\"的反直觉 bug;\n /// - **密钥安全**:提取器已剥掉 auth / model / endpoint,密钥永不进共享片段。\n ///\n /// 之所以\"整体替换\"是安全的:每次写 live 都会把当前片段合并进去,所以切走时\n /// 读到的 live 一定是\"片段 + 本地改动\"的超集,重提取只会丢掉用户真正删掉的键,\n /// 不会误删其它供应商共享的内容。\n ///\n /// **作用域**:Claude + Codex。Codex 提取器(`extract_codex_common_config`)\n /// 已剥离全部供应商专属与 cc-switch 注入内容:`model` / `model_provider` /\n /// 顶层 `base_url` / 整张 `model_providers` 表(含端点与统一会话桶)、\n /// `mcp_servers`(SSOT 在 DB 表)、顶层 `experimental_bearer_token`\n /// fallback、`model_catalog_json`、`web_search = \"disabled\"` 哨兵——密钥与\n /// 注入产物不会进共享片段。Gemini 暂未纳入,如需支持应单独验证后再加。\n ///\n /// 仅对**显式勾选\"写入通用配置\"**(`meta.common_config_enabled == Some(true)`)的\n /// 供应商生效;用户**显式清空**过片段(`_cleared`)时跳过,避免把用户主动清掉的\n /// 配置又塞回来。所有失败均为非致命,只记 warning,绝不阻断切换。\n fn sync_common_config_snippet_from_live(\n state: &AppState,\n app_type: &AppType,\n provider: &Provider,\n live_config: &Value,\n result: &mut SwitchResult,\n ) {\n // 作用域限定 Claude + Codex(见函数文档)。\n if !matches!(app_type, AppType::Claude | AppType::Codex) {\n return;\n }\n\n let opted_in = provider\n .meta\n .as_ref()\n .and_then(|meta| meta.common_config_enabled)\n == Some(true);\n if !opted_in {\n return;\n }\n\n match state.db.is_config_snippet_cleared(app_type.as_str()) {\n Ok(true) => return, // 用户显式清空过通用配置,尊重其选择,不再自动塞回\n Ok(false) => {}\n Err(err) => {\n log::warn!(\n \"Failed to read common config cleared flag for {}: {err}\",\n app_type.as_str()\n );\n return;\n }\n }\n\n let new_snippet = match Self::extract_common_config_snippet_from_settings(\n app_type.clone(),\n live_config,\n ) {\n Ok(snippet) => snippet,\n Err(err) => {\n log::warn!(\n \"Failed to extract common config from live for {} provider '{}': {err}\",\n app_type.as_str(),\n provider.id\n );\n return;\n }\n };\n\n // 未变化则跳过,避免无谓写库(不切 live 配置时这是常态路径)。\n let current = state\n .db\n .get_config_snippet(app_type.as_str())\n .ok()\n .flatten();\n if current.as_deref() == Some(new_snippet.as_str()) {\n return;\n }\n\n if let Err(err) = state\n .db\n .set_config_snippet(app_type.as_str(), Some(new_snippet))\n {\n log::warn!(\n \"Failed to persist synced common config for {} provider '{}': {err}\",\n app_type.as_str(),\n provider.id\n );\n result\n .warnings\n .push(format!(\"common_config_sync_failed:{}\", provider.id));\n }\n }\n\n /// Extract common config snippet from current provider\n ///\n /// Extracts the current provider's configuration and removes provider-specific fields\n /// (API keys, model settings, endpoints) to create a reusable common config snippet.\n pub fn extract_common_config_snippet(\n state: &AppState,\n app_type: AppType,\n ) -> Result<String, AppError> {\n // Get current provider\n let current_id = Self::current(state, app_type.clone())?;\n if current_id.is_empty() {\n return Err(AppError::Message(\"No current provider\".to_string()));\n }\n\n let providers = state.db.get_all_providers(app_type.as_str())?;\n let provider = providers\n .get(&current_id)\n .ok_or_else(|| AppError::Message(format!(\"Provider {current_id} not found\")))?;\n\n match app_type {\n AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),\n AppType::ClaudeDesktop => Ok(String::new()),\n AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),\n AppType::Grok => Ok(String::new()),\n AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),\n AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),\n AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),\n AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets\n }\n }\n\n /// Extract common config snippet from a config value (e.g. editor content).\n pub fn extract_common_config_snippet_from_settings(\n app_type: AppType,\n settings_config: &Value,\n ) -> Result<String, AppError> {\n match app_type {\n AppType::Claude => Self::extract_claude_common_config(settings_config),\n AppType::ClaudeDesktop => Ok(String::new()),\n AppType::Codex => Self::extract_codex_common_config(settings_config),\n AppType::Grok => Ok(String::new()),\n AppType::Gemini => Self::extract_gemini_common_config(settings_config),\n AppType::OpenCode => Self::extract_opencode_common_config(settings_config),\n AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),\n AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets\n }\n }\n\n /// 判断一个 env / 顶层配置键名是否为凭据/机密:凡命中一律不得写入共享的\n /// 通用配置片段。**故意从严**——多剥一个非机密键只是它不被共享(可恢复的小\n /// 不便),漏剥一个凭据则会把密钥注入到每个供应商(不可恢复的泄漏)。因此用\n /// 模式匹配覆盖整类,而非枚举具体名字(枚举永远会漏掉下一个 `*_API_KEY`)。\n ///\n /// 覆盖:Anthropic / OpenRouter / Google / OpenAI / Gemini 等 `*_API_KEY`\n /// (Claude provider 的凭据见 `Provider::resolve_usage_credentials`,确实支持\n /// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` /\n /// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password /\n /// 私钥命名。\n fn is_sensitive_config_key(name: &str) -> bool {\n let upper = name.to_ascii_uppercase();\n\n // 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但**不**误伤复数 `_TOKENS`\n // (CLAUDE_CODE_MAX_OUTPUT_TOKENS / MAX_THINKING_TOKENS 是正常可共享配置)。\n const SENSITIVE_SUFFIXES: &[&str] = &[\n \"_API_KEY\",\n \"_APIKEY\",\n \"_AUTH_TOKEN\",\n \"_TOKEN\",\n \"_ACCESS_KEY\",\n \"_ACCESS_KEY_ID\",\n \"_KEY_ID\",\n \"_PRIVATE_KEY\",\n ];\n const SENSITIVE_EXACT: &[&str] = &[\n \"APIKEY\",\n \"API_KEY\",\n \"TOKEN\",\n \"SECRET\",\n \"PASSWORD\",\n \"CREDENTIALS\",\n ];\n // contains:覆盖 AWS_SECRET_ACCESS_KEY / *_CLIENT_SECRET /\n // GOOGLE_APPLICATION_CREDENTIALS / AWS_BEARER_TOKEN_BEDROCK 等变体。\n const SENSITIVE_CONTAINS: &[&str] = &[\n \"SECRET\",\n \"PASSWORD\",\n \"PASSWD\",\n \"CREDENTIAL\",\n \"PRIVATE_KEY\",\n \"BEARER_TOKEN\",\n ];\n\n SENSITIVE_EXACT.contains(&upper.as_str())\n || SENSITIVE_SUFFIXES.iter().any(|s| upper.ends_with(s))\n || SENSITIVE_CONTAINS.iter().any(|c| upper.contains(c))\n }\n\n /// Extract common config for Claude (JSON format)\n fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {\n let mut config = settings.clone();\n\n // 供应商专属的**非机密**字段(模型 + 端点),不应共享。凭据/机密不在此列举,\n // 改由 `is_sensitive_config_key`(模式匹配)统一剥离,新供应商的 `*_API_KEY`\n // 等无需再手工补名单即可被覆盖。\n const ENV_PROVIDER_SPECIFIC_EXCLUDES: &[&str] = &[\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n // Fable 是 v3.16.3 新增的第四档模型映射,与 haiku/sonnet/opus 同属供应商专属,\n // 不得进入通用配置片段,否则会污染其它供应商(issue #4272)。\n \"ANTHROPIC_DEFAULT_FABLE_MODEL\",\n \"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME\",\n \"CLAUDE_CODE_SUBAGENT_MODEL\",\n // Context limits follow the actual upstream model. Sharing these\n // across providers can cap GPT/Kimi to the wrong window and make\n // Claude Code compact too early or miss the upstream limit.\n \"CLAUDE_CODE_MAX_CONTEXT_TOKENS\",\n \"CLAUDE_CODE_AUTO_COMPACT_WINDOW\",\n \"ANTHROPIC_BASE_URL\",\n ];\n\n const TOP_LEVEL_EXCLUDES: &[&str] = &[\n \"apiBaseUrl\",\n // Legacy model fields\n \"primaryModel\",\n \"smallFastModel\",\n ];\n\n // Remove env fields: provider-specific (models/endpoint) + 任何凭据键。\n if let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n let sensitive: Vec<String> = env\n .keys()\n .filter(|k| Self::is_sensitive_config_key(k))\n .cloned()\n .collect();\n for key in ENV_PROVIDER_SPECIFIC_EXCLUDES {\n env.remove(*key);\n }\n for key in &sensitive {\n env.remove(key);\n }\n // If env is empty after removal, remove the env object itself\n if env.is_empty() {\n config.as_object_mut().map(|obj| obj.remove(\"env\"));\n }\n }\n\n // Remove top-level fields: legacy model fields + 任何凭据键\n // (例如非标准的顶层 apiKey / api_key / *_TOKEN)。\n if let Some(obj) = config.as_object_mut() {\n let sensitive: Vec<String> = obj\n .keys()\n .filter(|k| Self::is_sensitive_config_key(k))\n .cloned()\n .collect();\n for key in TOP_LEVEL_EXCLUDES {\n obj.remove(*key);\n }\n for key in &sensitive {\n obj.remove(key);\n }\n }\n\n // Check if result is empty\n if config.as_object().is_none_or(|obj| obj.is_empty()) {\n return Ok(\"{}\".to_string());\n }\n\n serde_json::to_string_pretty(&config)\n .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n }\n\n /// Extract common config for Codex (TOML format)\n fn extract_codex_common_config(settings: &Value) -> Result<String, AppError> {\n // Codex config is stored as { \"auth\": {...}, \"config\": \"toml string\" }\n let config_toml = settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n\n if config_toml.is_empty() {\n return Ok(String::new());\n }\n\n let mut doc = config_toml\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| AppError::Message(format!(\"TOML parse error: {e}\")))?;\n\n // Remove provider-specific fields.\n let root = doc.as_table_mut();\n root.remove(\"model\");\n root.remove(\"model_provider\");\n // Legacy/alt formats might use a top-level base_url.\n root.remove(\"base_url\");\n // wire_api 与 base_url 同属供应商路由语义:无 model_provider 时\n // update_codex_toml_field / 前端 setCodexWireApi 都会把它落在顶层,\n // 进了片段会改写其它供应商的协议选择(chat vs responses)。\n root.remove(\"wire_api\");\n\n // Remove entire model_providers table (provider-specific configuration)\n root.remove(\"model_providers\");\n\n // MCP 服务器归 DB mcp_servers 表所有:进了共享片段会绕过按应用的\n // 启用状态被合并进所有勾选通用配置的供应商,且在通用配置编辑框里\n // 显示为一份\"重复\"的 MCP 配置。\n root.remove(\"mcp_servers\");\n // 历史错误格式 [mcp.servers] 一并剥离(与 strip_codex_mcp_servers_from_settings\n // 一致):sync_all_enabled 只管理 [mcp_servers.*],legacy 形态一旦进了\n // 片段就会被合并进所有供应商,且没有任何同步路径能清掉这个孤儿。\n if let Some(mcp_tbl) = root\n .get_mut(\"mcp\")\n .and_then(|item| item.as_table_like_mut())\n {\n mcp_tbl.remove(\"servers\");\n if mcp_tbl.is_empty() {\n root.remove(\"mcp\");\n }\n }\n\n // cc-switch 写 live 时注入的产物一律不进共享片段:\n // - experimental_bearer_token 正常写在 [model_providers.<id>] 内(上面\n // 整表已剥),但无活跃路由 / 内建保留 id / 路由表缺失三种 fallback\n // 会落在顶层——不剥等于把 API 密钥写进共享片段。\n root.remove(\"experimental_bearer_token\");\n // - model_catalog_json 指向按供应商生成的 catalog 投影文件(DB 为 SSOT)。\n root.remove(\"model_catalog_json\");\n // - web_search 只剥 cc-switch 注入的 \"disabled\" 哨兵;用户手设的其它值\n // 属于可共享偏好,保留。\n if root\n .get(crate::codex_config::CODEX_WEB_SEARCH_FIELD)\n .and_then(|item| item.as_str())\n == Some(crate::codex_config::CODEX_WEB_SEARCH_DISABLED)\n {\n root.remove(crate::codex_config::CODEX_WEB_SEARCH_FIELD);\n }\n\n // Clean up multiple empty lines (keep at most one blank line).\n let mut cleaned = String::new();\n let mut blank_run = 0usize;\n for line in doc.to_string().lines() {\n if line.trim().is_empty() {\n blank_run += 1;\n if blank_run <= 1 {\n cleaned.push('\\n');\n }\n continue;\n }\n blank_run = 0;\n cleaned.push_str(line);\n cleaned.push('\\n');\n }\n\n Ok(cleaned.trim().to_string())\n }\n\n /// Extract common config for Gemini (JSON format)\n ///\n /// Extracts `.env` values while excluding provider-specific credentials:\n /// - GOOGLE_GEMINI_BASE_URL\n /// - GEMINI_API_KEY\n fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {\n let env = settings.get(\"env\").and_then(|v| v.as_object());\n\n let mut snippet = serde_json::Map::new();\n if let Some(env) = env {\n for (key, value) in env {\n if key == \"GOOGLE_GEMINI_BASE_URL\" || key == \"GEMINI_API_KEY\" {\n continue;\n }\n let Value::String(v) = value else {\n continue;\n };\n let trimmed = v.trim();\n if !trimmed.is_empty() {\n snippet.insert(key.to_string(), Value::String(trimmed.to_string()));\n }\n }\n }\n\n if snippet.is_empty() {\n return Ok(\"{}\".to_string());\n }\n\n serde_json::to_string_pretty(&Value::Object(snippet))\n .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n }\n\n /// Extract common config for OpenCode (JSON format)\n fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {\n // OpenCode uses a different config structure with npm, options, models\n // For common config, we exclude provider-specific fields like apiKey\n let mut config = settings.clone();\n\n // Remove provider-specific fields\n if let Some(obj) = config.as_object_mut() {\n if let Some(options) = obj.get_mut(\"options\").and_then(|v| v.as_object_mut()) {\n options.remove(\"apiKey\");\n options.remove(\"baseURL\");\n }\n // Keep npm and models as they might be common\n }\n\n if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {\n return Ok(\"{}\".to_string());\n }\n\n serde_json::to_string_pretty(&config)\n .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n }\n\n /// Extract common config for OpenClaw (JSON format)\n fn extract_openclaw_common_config(settings: &Value) -> Result<String, AppError> {\n // OpenClaw uses a different config structure with baseUrl, apiKey, api, models\n // For common config, we exclude provider-specific fields like apiKey\n let mut config = settings.clone();\n\n // Remove provider-specific fields\n if let Some(obj) = config.as_object_mut() {\n obj.remove(\"apiKey\");\n obj.remove(\"baseUrl\");\n // Keep api and models as they might be common\n }\n\n if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {\n return Ok(\"{}\".to_string());\n }\n\n serde_json::to_string_pretty(&config)\n .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n }\n\n /// Import default configuration from live files (re-export)\n ///\n /// Returns `Ok(true)` if imported, `Ok(false)` if skipped.\n pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {\n import_default_config(state, app_type)\n }\n\n pub fn should_import_default_config_on_startup(\n state: &AppState,\n app_type: &AppType,\n ) -> Result<bool, AppError> {\n should_import_default_config_on_startup(state, app_type)\n }\n\n /// Read current live settings (re-export)\n pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {\n read_live_settings(app_type)\n }\n\n /// Get custom endpoints list (re-export)\n pub fn get_custom_endpoints(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n ) -> Result<Vec<CustomEndpoint>, AppError> {\n endpoints::get_custom_endpoints(state, app_type, provider_id)\n }\n\n /// Add custom endpoint (re-export)\n pub fn add_custom_endpoint(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n url: String,\n ) -> Result<(), AppError> {\n endpoints::add_custom_endpoint(state, app_type, provider_id, url)\n }\n\n /// Remove custom endpoint (re-export)\n pub fn remove_custom_endpoint(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n url: String,\n ) -> Result<(), AppError> {\n endpoints::remove_custom_endpoint(state, app_type, provider_id, url)\n }\n\n /// Update endpoint last used timestamp (re-export)\n pub fn update_endpoint_last_used(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n url: String,\n ) -> Result<(), AppError> {\n endpoints::update_endpoint_last_used(state, app_type, provider_id, url)\n }\n\n /// Update provider sort order\n pub fn update_sort_order(\n state: &AppState,\n app_type: AppType,\n updates: Vec<ProviderSortUpdate>,\n ) -> Result<bool, AppError> {\n let mut providers = state.db.get_all_providers(app_type.as_str())?;\n\n for update in updates {\n if let Some(provider) = providers.get_mut(&update.id) {\n provider.sort_index = Some(update.sort_index);\n state.db.save_provider(app_type.as_str(), provider)?;\n }\n }\n\n Ok(true)\n }\n\n /// Query provider usage (re-export)\n pub async fn query_usage(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n ) -> Result<UsageResult, AppError> {\n usage::query_usage(state, app_type, provider_id).await\n }\n\n /// Test usage script (re-export)\n #[allow(clippy::too_many_arguments)]\n pub async fn test_usage_script(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n script_code: &str,\n timeout: u64,\n api_key: Option<&str>,\n base_url: Option<&str>,\n access_token: Option<&str>,\n user_id: Option<&str>,\n template_type: Option<&str>,\n ) -> Result<UsageResult, AppError> {\n usage::test_usage_script(\n state,\n app_type,\n provider_id,\n script_code,\n timeout,\n api_key,\n base_url,\n access_token,\n user_id,\n template_type,\n )\n .await\n }\n\n pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {\n write_gemini_live(provider)\n }\n\n fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {\n match app_type {\n AppType::Claude => {\n if !provider.settings_config.is_object() {\n return Err(AppError::localized(\n \"provider.claude.settings.not_object\",\n \"Claude 配置必须是 JSON 对象\",\n \"Claude configuration must be a JSON object\",\n ));\n }\n }\n AppType::ClaudeDesktop => {\n crate::claude_desktop_config::validate_provider(provider)?;\n }\n AppType::Codex => {\n let settings = provider.settings_config.as_object().ok_or_else(|| {\n AppError::localized(\n \"provider.codex.settings.not_object\",\n \"Codex 配置必须是 JSON 对象\",\n \"Codex configuration must be a JSON object\",\n )\n })?;\n\n let auth = settings.get(\"auth\").ok_or_else(|| {\n AppError::localized(\n \"provider.codex.auth.missing\",\n format!(\"供应商 {} 缺少 auth 配置\", provider.id),\n format!(\"Provider {} is missing auth configuration\", provider.id),\n )\n })?;\n if !auth.is_object() {\n return Err(AppError::localized(\n \"provider.codex.auth.not_object\",\n format!(\"供应商 {} 的 auth 配置必须是 JSON 对象\", provider.id),\n format!(\n \"Provider {} auth configuration must be a JSON object\",\n provider.id\n ),\n ));\n }\n\n if let Some(config_value) = settings.get(\"config\") {\n if !(config_value.is_string() || config_value.is_null()) {\n return Err(AppError::localized(\n \"provider.codex.config.invalid_type\",\n \"Codex config 字段必须是字符串\",\n \"Codex config field must be a string\",\n ));\n }\n if let Some(cfg_text) = config_value.as_str() {\n crate::codex_config::validate_config_toml(cfg_text)?;\n }\n }\n }\n AppType::Grok => {\n let settings = provider.settings_config.as_object().ok_or_else(|| {\n AppError::localized(\n \"provider.grok.settings.not_object\",\n \"Grok 配置必须是 JSON 对象\",\n \"Grok configuration must be a JSON object\",\n )\n })?;\n let auth = settings.get(\"auth\").ok_or_else(|| {\n AppError::localized(\n \"provider.grok.auth.missing\",\n format!(\"供应商 {} 缺少 auth 配置\", provider.id),\n format!(\"Provider {} is missing auth configuration\", provider.id),\n )\n })?;\n if !auth.is_object() {\n return Err(AppError::localized(\n \"provider.grok.auth.not_object\",\n \"Grok auth 配置必须是 JSON 对象\",\n \"Grok auth configuration must be a JSON object\",\n ));\n }\n let config = settings\n .get(\"config\")\n .and_then(Value::as_str)\n .ok_or_else(|| {\n AppError::localized(\n \"provider.grok.config.missing\",\n \"Grok config 字段必须是 TOML 字符串\",\n \"Grok config must be a TOML string\",\n )\n })?;\n crate::grok_config::validate_config_toml(config)?;\n }\n AppType::Gemini => {\n use crate::gemini_config::validate_gemini_settings;\n validate_gemini_settings(&provider.settings_config)?\n }\n AppType::OpenCode => {\n // OpenCode uses a different config structure: { npm, options, models }\n // Basic validation - must be an object\n if !provider.settings_config.is_object() {\n return Err(AppError::localized(\n \"provider.opencode.settings.not_object\",\n \"OpenCode 配置必须是 JSON 对象\",\n \"OpenCode configuration must be a JSON object\",\n ));\n }\n }\n AppType::OpenClaw => {\n // OpenClaw uses config structure: { baseUrl, apiKey, api, models }\n // Basic validation - must be an object\n if !provider.settings_config.is_object() {\n return Err(AppError::localized(\n \"provider.openclaw.settings.not_object\",\n \"OpenClaw 配置必须是 JSON 对象\",\n \"OpenClaw configuration must be a JSON object\",\n ));\n }\n }\n AppType::Hermes => {\n // Hermes: accept any JSON object for now\n if !provider.settings_config.is_object() {\n return Err(AppError::localized(\n \"provider.hermes.settings.not_object\",\n \"Hermes 配置必须是 JSON 对象\",\n \"Hermes configuration must be a JSON object\",\n ));\n }\n }\n }\n\n // Validate and clean UsageScript configuration (common for all app types)\n if let Some(meta) = &provider.meta {\n if let Some(multiplier) = meta.cost_multiplier.as_deref() {\n validate_cost_multiplier(multiplier)?;\n }\n if let Some(source) = meta.pricing_model_source.as_deref() {\n validate_pricing_source(source)?;\n }\n if let Some(usage_script) = &meta.usage_script {\n validate_usage_script(usage_script)?;\n }\n }\n\n Ok(())\n }\n\n #[allow(dead_code)]\n fn extract_credentials(\n provider: &Provider,\n app_type: &AppType,\n ) -> Result<(String, String), AppError> {\n match app_type {\n AppType::Claude => {\n let env = provider\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.claude.env.missing\",\n \"配置格式错误: 缺少 env\",\n \"Invalid configuration: missing env section\",\n )\n })?;\n\n let api_key = env\n .get(\"ANTHROPIC_AUTH_TOKEN\")\n .or_else(|| env.get(\"ANTHROPIC_API_KEY\"))\n .and_then(|v| v.as_str())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.claude.api_key.missing\",\n \"缺少 API Key\",\n \"API key is missing\",\n )\n })?\n .to_string();\n\n let base_url = env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.claude.base_url.missing\",\n \"缺少 ANTHROPIC_BASE_URL 配置\",\n \"Missing ANTHROPIC_BASE_URL configuration\",\n )\n })?\n .to_string();\n\n Ok((api_key, base_url))\n }\n AppType::ClaudeDesktop => {\n let credentials =\n crate::claude_desktop_config::direct_gateway_credentials(provider)?;\n Ok((credentials.api_key, credentials.base_url))\n }\n AppType::Codex => {\n let _auth = provider\n .settings_config\n .get(\"auth\")\n .and_then(|v| v.as_object())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.codex.auth.missing\",\n \"配置格式错误: 缺少 auth\",\n \"Invalid configuration: missing auth section\",\n )\n })?;\n\n let config_toml = provider\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n\n let api_key = crate::codex_config::extract_codex_api_key(\n provider.settings_config.get(\"auth\"),\n Some(config_toml),\n )\n .ok_or_else(|| {\n AppError::localized(\n \"provider.codex.api_key.missing\",\n \"缺少 API Key\",\n \"API key is missing\",\n )\n })?;\n\n let base_url = if config_toml.contains(\"base_url\") {\n let re = Regex::new(r#\"base_url\\s*=\\s*[\"']([^\"']+)[\"']\"#).map_err(|e| {\n AppError::localized(\n \"provider.regex_init_failed\",\n format!(\"正则初始化失败: {e}\"),\n format!(\"Failed to initialize regex: {e}\"),\n )\n })?;\n re.captures(config_toml)\n .and_then(|caps| caps.get(1))\n .map(|m| m.as_str().to_string())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.codex.base_url.invalid\",\n \"config.toml 中 base_url 格式错误\",\n \"base_url in config.toml has invalid format\",\n )\n })?\n } else {\n return Err(AppError::localized(\n \"provider.codex.base_url.missing\",\n \"config.toml 中缺少 base_url 配置\",\n \"base_url is missing from config.toml\",\n ));\n };\n\n Ok((api_key, base_url))\n }\n AppType::Grok => {\n let auth = provider\n .settings_config\n .get(\"auth\")\n .and_then(Value::as_object)\n .ok_or_else(|| {\n AppError::localized(\n \"provider.grok.auth.missing\",\n \"配置格式错误: 缺少 auth\",\n \"Invalid configuration: missing auth section\",\n )\n })?;\n let api_key = [\"OPENAI_API_KEY\", \"XAI_API_KEY\", \"GROK_API_KEY\"]\n .into_iter()\n .find_map(|key| auth.get(key).and_then(Value::as_str))\n .map(str::trim)\n .filter(|key| !key.is_empty())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.grok.api_key.missing\",\n \"缺少 API Key\",\n \"API key is missing\",\n )\n })?\n .to_string();\n let config = provider\n .settings_config\n .get(\"config\")\n .and_then(Value::as_str)\n .unwrap_or_default();\n let base_url = crate::grok_config::active_base_url(config).ok_or_else(|| {\n AppError::localized(\n \"provider.grok.base_url.missing\",\n \"Grok config.toml 中缺少 base_url 配置\",\n \"base_url is missing from Grok config.toml\",\n )\n })?;\n Ok((api_key, base_url))\n }\n AppType::Gemini => {\n use crate::gemini_config::json_to_env;\n\n let env_map = json_to_env(&provider.settings_config)?;\n\n let api_key = env_map.get(\"GEMINI_API_KEY\").cloned().ok_or_else(|| {\n AppError::localized(\n \"gemini.missing_api_key\",\n \"缺少 GEMINI_API_KEY\",\n \"Missing GEMINI_API_KEY\",\n )\n })?;\n\n let base_url = env_map\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .cloned()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com\".to_string());\n\n Ok((api_key, base_url))\n }\n AppType::OpenCode => {\n // OpenCode uses options.apiKey and options.baseURL\n let options = provider\n .settings_config\n .get(\"options\")\n .and_then(|v| v.as_object())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.opencode.options.missing\",\n \"配置格式错误: 缺少 options\",\n \"Invalid configuration: missing options section\",\n )\n })?;\n\n let api_key = options\n .get(\"apiKey\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.opencode.api_key.missing\",\n \"缺少 API Key\",\n \"API key is missing\",\n )\n })?\n .to_string();\n\n let base_url = options\n .get(\"baseURL\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\")\n .to_string();\n\n Ok((api_key, base_url))\n }\n AppType::OpenClaw | AppType::Hermes => {\n // OpenClaw/Hermes use apiKey and baseUrl directly on the object\n let api_key = provider\n .settings_config\n .get(\"apiKey\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.openclaw.api_key.missing\",\n \"缺少 API Key\",\n \"API key is missing\",\n )\n })?\n .to_string();\n\n let base_url = provider\n .settings_config\n .get(\"baseUrl\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\")\n .to_string();\n\n Ok((api_key, base_url))\n }\n }\n }\n}\n\n/// Normalize Claude model keys in a JSON value\n///\n/// Reads old key (ANTHROPIC_SMALL_FAST_MODEL), writes new keys (DEFAULT_*), and deletes old key.\npub(crate) fn normalize_claude_models_in_value(settings: &mut Value) -> bool {\n let mut changed = false;\n let env = match settings.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n Some(obj) => obj,\n None => return changed,\n };\n\n let model = env\n .get(\"ANTHROPIC_MODEL\")\n .and_then(|v| v.as_str())\n .map(|s| s.to_string());\n let small_fast = env\n .get(\"ANTHROPIC_SMALL_FAST_MODEL\")\n .and_then(|v| v.as_str())\n .map(|s| s.to_string());\n\n let current_haiku = env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str())\n .map(|s| s.to_string());\n let current_sonnet = env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str())\n .map(|s| s.to_string());\n let current_opus = env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str())\n .map(|s| s.to_string());\n\n let target_haiku = current_haiku\n .or_else(|| small_fast.clone())\n .or_else(|| model.clone());\n let target_sonnet = current_sonnet\n .or_else(|| model.clone())\n .or_else(|| small_fast.clone());\n let target_opus = current_opus\n .or_else(|| model.clone())\n .or_else(|| small_fast.clone());\n\n if env.get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\").is_none() {\n if let Some(v) = target_haiku {\n env.insert(\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\".to_string(),\n Value::String(v),\n );\n changed = true;\n }\n }\n if env.get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\").is_none() {\n if let Some(v) = target_sonnet {\n env.insert(\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\".to_string(),\n Value::String(v),\n );\n changed = true;\n }\n }\n if env.get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\").is_none() {\n if let Some(v) = target_opus {\n env.insert(\"ANTHROPIC_DEFAULT_OPUS_MODEL\".to_string(), Value::String(v));\n changed = true;\n }\n }\n\n if env.remove(\"ANTHROPIC_SMALL_FAST_MODEL\").is_some() {\n changed = true;\n }\n\n changed\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ProviderSortUpdate {\n pub id: String,\n #[serde(rename = \"sortIndex\")]\n pub sort_index: usize,\n}\n\n// ============================================================================\n// 统一供应商(Universal Provider)服务方法\n// ============================================================================\n\nuse crate::provider::UniversalProvider;\nuse std::collections::HashMap;\n\nimpl ProviderService {\n /// 获取所有统一供应商\n pub fn list_universal(\n state: &AppState,\n ) -> Result<HashMap<String, UniversalProvider>, AppError> {\n state.db.get_all_universal_providers()\n }\n\n /// 获取单个统一供应商\n pub fn get_universal(\n state: &AppState,\n id: &str,\n ) -> Result<Option<UniversalProvider>, AppError> {\n state.db.get_universal_provider(id)\n }\n\n /// 添加或更新统一供应商(不自动同步,需手动调用 sync_universal_to_apps)\n pub fn upsert_universal(\n state: &AppState,\n provider: UniversalProvider,\n ) -> Result<bool, AppError> {\n // 保存统一供应商\n state.db.save_universal_provider(&provider)?;\n\n Ok(true)\n }\n\n /// 删除统一供应商\n pub fn delete_universal(state: &AppState, id: &str) -> Result<bool, AppError> {\n // 获取统一供应商(用于删除生成的子供应商)\n let provider = state.db.get_universal_provider(id)?;\n\n // 删除统一供应商\n state.db.delete_universal_provider(id)?;\n\n // 删除生成的子供应商\n if let Some(p) = provider {\n if p.apps.claude {\n let claude_id = format!(\"universal-claude-{id}\");\n let _ = state.db.delete_provider(\"claude\", &claude_id);\n }\n if p.apps.codex {\n let codex_id = format!(\"universal-codex-{id}\");\n let _ = state.db.delete_provider(\"codex\", &codex_id);\n }\n if p.apps.gemini {\n let gemini_id = format!(\"universal-gemini-{id}\");\n let _ = state.db.delete_provider(\"gemini\", &gemini_id);\n }\n }\n\n Ok(true)\n }\n\n /// 同步统一供应商到各应用\n pub fn sync_universal_to_apps(state: &AppState, id: &str) -> Result<bool, AppError> {\n let provider = state\n .db\n .get_universal_provider(id)?\n .ok_or_else(|| AppError::Message(format!(\"统一供应商 {id} 不存在\")))?;\n\n // 同步到 Claude\n if let Some(mut claude_provider) = provider.to_claude_provider() {\n // 合并已有配置\n if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, \"claude\")? {\n let mut merged = existing.settings_config.clone();\n Self::merge_json(&mut merged, &claude_provider.settings_config);\n claude_provider.settings_config = merged;\n }\n state.db.save_provider(\"claude\", &claude_provider)?;\n } else {\n // 如果禁用了 Claude,删除对应的子供应商\n let claude_id = format!(\"universal-claude-{id}\");\n let _ = state.db.delete_provider(\"claude\", &claude_id);\n }\n\n // 同步到 Codex\n if let Some(mut codex_provider) = provider.to_codex_provider() {\n // 合并已有配置\n if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, \"codex\")? {\n let mut merged = existing.settings_config.clone();\n Self::merge_json(&mut merged, &codex_provider.settings_config);\n codex_provider.settings_config = merged;\n }\n state.db.save_provider(\"codex\", &codex_provider)?;\n } else {\n let codex_id = format!(\"universal-codex-{id}\");\n let _ = state.db.delete_provider(\"codex\", &codex_id);\n }\n\n // 同步到 Gemini\n if let Some(mut gemini_provider) = provider.to_gemini_provider() {\n // 合并已有配置\n if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, \"gemini\")? {\n let mut merged = existing.settings_config.clone();\n Self::merge_json(&mut merged, &gemini_provider.settings_config);\n gemini_provider.settings_config = merged;\n }\n state.db.save_provider(\"gemini\", &gemini_provider)?;\n } else {\n let gemini_id = format!(\"universal-gemini-{id}\");\n let _ = state.db.delete_provider(\"gemini\", &gemini_id);\n }\n\n Ok(true)\n }\n\n /// 递归合并 JSON:base 为底,patch 覆盖同名字段\n fn merge_json(base: &mut serde_json::Value, patch: &serde_json::Value) {\n use serde_json::Value;\n\n match (base, patch) {\n (Value::Object(base_map), Value::Object(patch_map)) => {\n for (k, v_patch) in patch_map {\n match base_map.get_mut(k) {\n Some(v_base) => Self::merge_json(v_base, v_patch),\n None => {\n base_map.insert(k.clone(), v_patch.clone());\n }\n }\n }\n }\n // 其它类型:直接覆盖\n (base_val, patch_val) => {\n *base_val = patch_val.clone();\n }\n }\n }\n}\n"}, "ef23931ec0d06fa7680728f79acdc6527eba3632": {"t": 1785761096.4962819, "n": 131265, "h": "97f12c434e702f60", "c": "//! Provider service module\n//!\n//! Handles provider CRUD operations, switching, and configuration management.\n\nmod endpoints;\nmod gemini_auth;\nmod live;\nmod usage;\n\nuse indexmap::IndexMap;\nuse regex::Regex;\nuse serde::Deserialize;\nuse serde_json::Value;\n\nuse crate::app_config::AppType;\nuse crate::database::{validate_cost_multiplier, validate_pricing_source};\nuse crate::error::AppError;\nuse crate::provider::{Provider, UsageResult};\nuse crate::services::mcp::McpService;\nuse crate::settings::CustomEndpoint;\nuse crate::store::AppState;\n\n// Re-export sub-module functions for external access\npub use live::{\n import_default_config, import_hermes_providers_from_live, import_openclaw_providers_from_live,\n import_opencode_providers_from_live, read_live_settings,\n should_import_default_config_on_startup, sync_current_to_live,\n};\n\n// Internal re-exports (pub(crate))\npub(crate) use live::sanitize_claude_settings_for_live;\npub(crate) use live::{\n build_effective_settings_with_common_config, normalize_provider_common_config_for_storage,\n provider_exists_in_live_config, strip_common_config_from_live_settings,\n sync_current_provider_for_app_to_live, write_live_with_common_config,\n};\n\n// Internal re-exports\nuse live::{\n remove_hermes_provider_from_live, remove_openclaw_provider_from_live,\n remove_opencode_provider_from_live, write_gemini_live,\n};\nuse usage::validate_usage_script;\n\n/// 统一会话开关变更后,立即按新开关状态重写当前官方 Codex 供应商的\n/// live 配置,使开关即时生效(无需等下一次切换)。\n/// 当前供应商非官方(或不存在)时为 no-op:注入只作用于官方配置,\n/// 第三方 live 配置不受开关影响。\npub fn reapply_current_codex_official_live(state: &AppState) -> Result<bool, AppError> {\n let current_id = ProviderService::current(state, AppType::Codex)?;\n if current_id.is_empty() {\n return Ok(false);\n }\n let providers = state.db.get_all_providers(AppType::Codex.as_str())?;\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n if provider.category.as_deref() != Some(\"official\") {\n return Ok(false);\n }\n\n // 代理接管期间 live 归代理所有(开启代理时官方供应商只警告不拦截,\n // 二者可以共存)。与切换/保存路径一致:以 backup/占位符为所有权信号,\n // 只更新备份,注入后的配置由接管释放时的恢复路径落盘。\n let has_live_backup =\n futures::executor::block_on(state.db.get_live_backup(AppType::Codex.as_str()))\n .ok()\n .flatten()\n .is_some();\n let live_taken_over = state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex);\n if has_live_backup || live_taken_over {\n futures::executor::block_on(\n state\n .proxy_service\n .update_live_backup_from_provider(AppType::Codex.as_str(), provider),\n )\n .map_err(|e| AppError::Message(format!(\"更新 Live 备份失败: {e}\")))?;\n return Ok(true);\n }\n\n live::write_live_with_common_config(&state.db, &AppType::Codex, provider)?;\n Ok(true)\n}\n\n/// Provider business logic service\npub struct ProviderService;\n\n/// Result of a provider switch operation, including any non-fatal warnings\n#[derive(Debug, serde::Serialize, Default)]\n#[serde(rename_all = \"camelCase\")]\npub struct SwitchResult {\n pub warnings: Vec<String>,\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n #[cfg(any(target_os = \"macos\", windows))]\n use crate::claude_desktop_config::PROFILE_ID;\n use crate::config::{get_claude_settings_path, read_json_file, write_json_file};\n use crate::database::Database;\n #[cfg(any(target_os = \"macos\", windows))]\n use crate::provider::{ClaudeDesktopMode, ClaudeDesktopModelRoute};\n use crate::provider::{ProviderMeta, UsageScript};\n use crate::proxy::types::ProxyConfig;\n use crate::store::AppState;\n use serde_json::json;\n use serial_test::serial;\n use std::env;\n use std::fs;\n use std::path::{Path, PathBuf};\n use std::sync::{Arc, Mutex, OnceLock};\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: Option<String>,\n #[cfg(windows)]\n original_local_app_data: Option<String>,\n original_userprofile: Option<String>,\n original_test_home: Option<String>,\n }\n\n impl TempHome {\n fn new() -> Self {\n let dir = TempDir::new().expect(\"failed to create temp home\");\n let original_home = env::var(\"HOME\").ok();\n #[cfg(windows)]\n let original_local_app_data = env::var(\"LOCALAPPDATA\").ok();\n let original_userprofile = env::var(\"USERPROFILE\").ok();\n let original_test_home = env::var(\"CC_SWITCH_TEST_HOME\").ok();\n\n env::set_var(\"HOME\", dir.path());\n #[cfg(windows)]\n env::set_var(\"LOCALAPPDATA\", dir.path().join(\"AppData\").join(\"Local\"));\n env::set_var(\"USERPROFILE\", dir.path());\n env::set_var(\"CC_SWITCH_TEST_HOME\", dir.path());\n\n Self {\n dir,\n original_home,\n #[cfg(windows)]\n original_local_app_data,\n original_userprofile,\n original_test_home,\n }\n }\n }\n\n impl Drop for TempHome {\n fn drop(&mut self) {\n match &self.original_home {\n Some(value) => env::set_var(\"HOME\", value),\n None => env::remove_var(\"HOME\"),\n }\n\n #[cfg(windows)]\n {\n match &self.original_local_app_data {\n Some(value) => env::set_var(\"LOCALAPPDATA\", value),\n None => env::remove_var(\"LOCALAPPDATA\"),\n }\n }\n\n match &self.original_userprofile {\n Some(value) => env::set_var(\"USERPROFILE\", value),\n None => env::remove_var(\"USERPROFILE\"),\n }\n\n match &self.original_test_home {\n Some(value) => env::set_var(\"CC_SWITCH_TEST_HOME\", value),\n None => env::remove_var(\"CC_SWITCH_TEST_HOME\"),\n }\n }\n }\n\n #[cfg(windows)]\n fn claude_desktop_profile_path(home: &Path) -> PathBuf {\n home.join(\"AppData\")\n .join(\"Local\")\n .join(\"Claude-3p\")\n .join(\"configLibrary\")\n .join(format!(\"{PROFILE_ID}.json\"))\n }\n\n #[cfg(target_os = \"macos\")]\n fn claude_desktop_profile_path(home: &Path) -> PathBuf {\n home.join(\"Library\")\n .join(\"Application Support\")\n .join(\"Claude-3p\")\n .join(\"configLibrary\")\n .join(format!(\"{PROFILE_ID}.json\"))\n }\n\n fn test_guard() -> std::sync::MutexGuard<'static, ()> {\n static LOCK: OnceLock<Mutex<()>> = OnceLock::new();\n LOCK.get_or_init(|| Mutex::new(()))\n .lock()\n .unwrap_or_else(|err| err.into_inner())\n }\n\n fn with_test_home<T>(test: impl FnOnce(&AppState, &Path) -> T) -> T {\n let _guard = test_guard();\n let temp = tempfile::tempdir().expect(\"tempdir\");\n let old_test_home = std::env::var_os(\"CC_SWITCH_TEST_HOME\");\n let old_home = std::env::var_os(\"HOME\");\n std::env::set_var(\"CC_SWITCH_TEST_HOME\", temp.path());\n std::env::set_var(\"HOME\", temp.path());\n\n let db = Arc::new(Database::memory().expect(\"in-memory database\"));\n let state = AppState::new(db);\n let result = test(&state, temp.path());\n\n match old_test_home {\n Some(value) => std::env::set_var(\"CC_SWITCH_TEST_HOME\", value),\n None => std::env::remove_var(\"CC_SWITCH_TEST_HOME\"),\n }\n match old_home {\n Some(value) => std::env::set_var(\"HOME\", value),\n None => std::env::remove_var(\"HOME\"),\n }\n\n result\n }\n\n fn codex_settings(base_url: &str, api_key: &str) -> Value {\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": api_key\n },\n \"config\": format!(\n \"model_provider = \\\"custom\\\"\\n\\\n [model_providers.custom]\\n\\\n name = \\\"custom\\\"\\n\\\n base_url = \\\"{base_url}\\\"\\n\\\n wire_api = \\\"chat\\\"\\n\"\n )\n })\n }\n\n fn usage_script_with_credentials(\n api_key: Option<&str>,\n base_url: Option<&str>,\n template_type: Option<&str>,\n ) -> UsageScript {\n UsageScript {\n enabled: true,\n language: \"javascript\".to_string(),\n code: \"return { remaining: 1, unit: 'USD' };\".to_string(),\n timeout: Some(10),\n api_key: api_key.map(str::to_string),\n base_url: base_url.map(str::to_string),\n access_token: None,\n user_id: None,\n template_type: template_type.map(str::to_string),\n auto_query_interval: None,\n coding_plan_provider: None,\n access_key_id: Some(\"ak-test\".to_string()),\n secret_access_key: Some(\"sk-test\".to_string()),\n }\n }\n\n fn codex_provider_with_usage(\n id: &str,\n base_url: &str,\n api_key: &str,\n usage_api_key: Option<&str>,\n usage_base_url: Option<&str>,\n template_type: Option<&str>,\n ) -> Provider {\n let mut provider = Provider::with_id(\n id.to_string(),\n format!(\"Provider {id}\"),\n codex_settings(base_url, api_key),\n None,\n );\n provider.meta = Some(ProviderMeta {\n usage_script: Some(usage_script_with_credentials(\n usage_api_key,\n usage_base_url,\n template_type,\n )),\n ..Default::default()\n });\n provider\n }\n\n fn openclaw_provider(id: &str) -> Provider {\n Provider {\n id: id.to_string(),\n name: format!(\"Provider {id}\"),\n settings_config: json!({\n \"baseUrl\": \"https://api.deepseek.com\",\n \"apiKey\": \"test-key\",\n \"api\": \"openai-completions\",\n \"models\": [],\n }),\n website_url: None,\n category: Some(\"custom\".to_string()),\n created_at: Some(1),\n sort_index: Some(0),\n notes: None,\n meta: None,\n icon: None,\n icon_color: None,\n in_failover_queue: false,\n }\n }\n\n fn opencode_provider(id: &str) -> Provider {\n Provider {\n id: id.to_string(),\n name: format!(\"Provider {id}\"),\n settings_config: json!({\n \"npm\": \"@ai-sdk/openai-compatible\",\n \"name\": format!(\"Provider {id}\"),\n \"options\": {\n \"baseURL\": \"https://api.example.com/v1\",\n \"apiKey\": \"test-key\"\n },\n \"models\": {\n \"gpt-4o\": {\n \"name\": \"GPT-4o\"\n }\n }\n }),\n website_url: None,\n category: Some(\"custom\".to_string()),\n created_at: Some(1),\n sort_index: Some(0),\n notes: None,\n meta: None,\n icon: None,\n icon_color: None,\n in_failover_queue: false,\n }\n }\n\n fn opencode_omo_provider(id: &str, category: &str) -> Provider {\n let mut settings = serde_json::Map::new();\n settings.insert(\n \"agents\".to_string(),\n json!({\n \"writer\": {\n \"model\": \"gpt-4o-mini\"\n }\n }),\n );\n if category == \"omo\" {\n settings.insert(\n \"categories\".to_string(),\n json!({\n \"default\": [\"writer\"]\n }),\n );\n }\n settings.insert(\n \"otherFields\".to_string(),\n json!({\n \"theme\": \"dark\"\n }),\n );\n\n Provider {\n id: id.to_string(),\n name: format!(\"Provider {id}\"),\n settings_config: Value::Object(settings),\n website_url: None,\n category: Some(category.to_string()),\n created_at: Some(1),\n sort_index: Some(0),\n notes: None,\n meta: None,\n icon: None,\n icon_color: None,\n in_failover_queue: false,\n }\n }\n\n fn omo_config_path(home: &Path, category: &str) -> PathBuf {\n home.join(\".config\").join(\"opencode\").join(match category {\n \"omo\" => crate::services::omo::STANDARD.preferred_filename,\n \"omo-slim\" => crate::services::omo::SLIM.preferred_filename,\n other => panic!(\"unexpected OMO category in test: {other}\"),\n })\n }\n\n #[test]\n #[serial]\n fn add_clears_usage_credentials_that_match_provider_config() {\n with_test_home(|state, _| {\n let provider = codex_provider_with_usage(\n \"codex-a\",\n \"https://api.a.example/v1/\",\n \"sk-a\",\n Some(\" sk-a \"),\n Some(\" https://api.a.example/v1/ \"),\n None,\n );\n\n ProviderService::add(state, AppType::Codex, provider, false).expect(\"add provider\");\n\n let saved = state\n .db\n .get_provider_by_id(\"codex-a\", AppType::Codex.as_str())\n .expect(\"query saved provider\")\n .expect(\"saved provider should exist\");\n let script = saved\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script.api_key, None);\n assert_eq!(script.base_url, None);\n });\n }\n\n #[test]\n #[serial]\n fn update_preserves_usage_credentials_that_only_match_previous_config() {\n with_test_home(|state, _| {\n let provider = codex_provider_with_usage(\n \"codex-usage-old\",\n \"https://api.a.example/v1/\",\n \"sk-a\",\n Some(\"sk-a\"),\n Some(\"https://api.a.example/v1/\"),\n None,\n );\n state\n .db\n .save_provider(AppType::Codex.as_str(), &provider)\n .expect(\"seed provider with explicit usage credentials\");\n\n let mut updated = provider.clone();\n updated.settings_config = codex_settings(\"https://api.b.example/v1/\", \"sk-b\");\n\n ProviderService::update(state, AppType::Codex, None, updated)\n .expect(\"update provider main credentials\");\n\n let saved = state\n .db\n .get_provider_by_id(\"codex-usage-old\", AppType::Codex.as_str())\n .expect(\"query updated provider\")\n .expect(\"updated provider should exist\");\n let script = saved\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script.api_key.as_deref(), Some(\"sk-a\"));\n assert_eq!(\n script.base_url.as_deref(),\n Some(\"https://api.a.example/v1/\")\n );\n assert_eq!(\n saved.resolve_usage_credentials(&AppType::Codex),\n (\"https://api.b.example/v1\".to_string(), \"sk-b\".to_string())\n );\n });\n }\n\n #[test]\n #[serial]\n fn copied_provider_uses_edited_credentials_after_add_clears_mirrored_usage_credentials() {\n with_test_home(|state, _| {\n let copied_provider = codex_provider_with_usage(\n \"codex-copy\",\n \"https://api.a.example/v1/\",\n \"sk-a\",\n Some(\"sk-a\"),\n Some(\"https://api.a.example/v1/\"),\n None,\n );\n\n ProviderService::add(state, AppType::Codex, copied_provider, false)\n .expect(\"add copied provider\");\n\n let saved_after_add = state\n .db\n .get_provider_by_id(\"codex-copy\", AppType::Codex.as_str())\n .expect(\"query copied provider\")\n .expect(\"copied provider should exist\");\n let script_after_add = saved_after_add\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n assert_eq!(script_after_add.api_key, None);\n assert_eq!(script_after_add.base_url, None);\n\n let mut edited_provider = saved_after_add.clone();\n edited_provider.settings_config = codex_settings(\"https://api.b.example/v1/\", \"sk-b\");\n\n ProviderService::update(state, AppType::Codex, None, edited_provider)\n .expect(\"edit copied provider credentials\");\n\n let saved_after_update = state\n .db\n .get_provider_by_id(\"codex-copy\", AppType::Codex.as_str())\n .expect(\"query edited provider\")\n .expect(\"edited provider should exist\");\n let script_after_update = saved_after_update\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script_after_update.api_key, None);\n assert_eq!(script_after_update.base_url, None);\n assert_eq!(\n saved_after_update.resolve_usage_credentials(&AppType::Codex),\n (\"https://api.b.example/v1\".to_string(), \"sk-b\".to_string())\n );\n });\n }\n\n #[test]\n #[serial]\n fn update_clears_usage_credentials_that_match_current_config() {\n with_test_home(|state, _| {\n let provider = codex_provider_with_usage(\n \"codex-current\",\n \"https://api.a.example/v1\",\n \"sk-a\",\n Some(\"sk-usage\"),\n Some(\"https://usage.example/api\"),\n None,\n );\n state\n .db\n .save_provider(AppType::Codex.as_str(), &provider)\n .expect(\"seed provider with distinct usage credentials\");\n\n let mut updated = provider.clone();\n updated.settings_config = codex_settings(\"https://api.b.example/v1/\", \"sk-b\");\n updated.meta = Some(ProviderMeta {\n usage_script: Some(usage_script_with_credentials(\n Some(\" sk-b \"),\n Some(\" https://api.b.example/v1/ \"),\n None,\n )),\n ..Default::default()\n });\n\n ProviderService::update(state, AppType::Codex, None, updated)\n .expect(\"update provider with redundant usage credentials\");\n\n let saved = state\n .db\n .get_provider_by_id(\"codex-current\", AppType::Codex.as_str())\n .expect(\"query updated provider\")\n .expect(\"updated provider should exist\");\n let script = saved\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script.api_key, None);\n assert_eq!(script.base_url, None);\n });\n }\n\n #[test]\n #[serial]\n fn add_preserves_distinct_usage_credentials() {\n with_test_home(|state, _| {\n let provider = codex_provider_with_usage(\n \"codex-distinct\",\n \"https://api.main.example/v1\",\n \"sk-main\",\n Some(\"sk-usage\"),\n Some(\"https://usage.example/api\"),\n None,\n );\n\n ProviderService::add(state, AppType::Codex, provider, false).expect(\"add provider\");\n\n let saved = state\n .db\n .get_provider_by_id(\"codex-distinct\", AppType::Codex.as_str())\n .expect(\"query saved provider\")\n .expect(\"saved provider should exist\");\n let script = saved\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script.api_key.as_deref(), Some(\"sk-usage\"));\n assert_eq!(\n script.base_url.as_deref(),\n Some(\"https://usage.example/api\")\n );\n });\n }\n\n #[test]\n #[serial]\n fn add_does_not_clear_token_plan_credentials() {\n with_test_home(|state, _| {\n let provider = codex_provider_with_usage(\n \"codex-token-plan\",\n \"https://api.plan.example/v1\",\n \"sk-plan\",\n Some(\"sk-plan\"),\n Some(\"https://api.plan.example/v1\"),\n Some(\"token_plan\"),\n );\n\n ProviderService::add(state, AppType::Codex, provider, false).expect(\"add provider\");\n\n let saved = state\n .db\n .get_provider_by_id(\"codex-token-plan\", AppType::Codex.as_str())\n .expect(\"query saved provider\")\n .expect(\"saved provider should exist\");\n let script = saved\n .meta\n .as_ref()\n .and_then(|meta| meta.usage_script.as_ref())\n .expect(\"usage script should remain\");\n\n assert_eq!(script.api_key.as_deref(), Some(\"sk-plan\"));\n assert_eq!(\n script.base_url.as_deref(),\n Some(\"https://api.plan.example/v1\")\n );\n assert_eq!(script.access_key_id.as_deref(), Some(\"ak-test\"));\n assert_eq!(script.secret_access_key.as_deref(), Some(\"sk-test\"));\n });\n }\n\n #[test]\n fn validate_provider_settings_rejects_missing_auth() {\n let provider = Provider::with_id(\n \"codex\".into(),\n \"Codex\".into(),\n json!({ \"config\": \"base_url = \\\"https://example.com\\\"\" }),\n None,\n );\n let err = ProviderService::validate_provider_settings(&AppType::Codex, &provider)\n .expect_err(\"missing auth should be rejected\");\n assert!(\n err.to_string().contains(\"auth\"),\n \"expected auth error, got {err:?}\"\n );\n }\n\n #[test]\n fn extract_claude_common_config_strips_all_credentials_keeps_shareable() {\n // env 混入多种凭据(Anthropic/OpenRouter/Google/OpenAI/Gemini + AWS/Vertex)\n // 与可共享配置;顶层混入非标准的 apiKey/api_key 凭据与正常设置。\n let settings = json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"sk-ant\",\n \"ANTHROPIC_AUTH_TOKEN\": \"tok-ant\",\n \"OPENROUTER_API_KEY\": \"sk-or\",\n \"GOOGLE_API_KEY\": \"g-key\",\n \"OPENAI_API_KEY\": \"sk-oai\",\n \"GEMINI_API_KEY\": \"g-gem\",\n \"AWS_ACCESS_KEY_ID\": \"AKIA\",\n \"AWS_SECRET_ACCESS_KEY\": \"secret\",\n \"AWS_SESSION_TOKEN\": \"sess\",\n \"GOOGLE_APPLICATION_CREDENTIALS\": \"/path/creds.json\",\n \"AWS_BEARER_TOKEN_BEDROCK\": \"bedrock-tok\",\n \"ANTHROPIC_BASE_URL\": \"https://example.com\",\n \"ANTHROPIC_MODEL\": \"claude-x\",\n // 可共享、非机密配置(复数 _TOKENS 不应被误剥)\n \"ENABLE_TOOL_SEARCH\": \"true\",\n \"CLAUDE_CODE_MAX_OUTPUT_TOKENS\": \"8192\"\n },\n \"apiKey\": \"sk-top\",\n \"api_key\": \"sk-top2\",\n \"theme\": \"dark\",\n \"includeCoAuthoredBy\": false\n });\n\n let snippet = ProviderService::extract_claude_common_config(&settings)\n .expect(\"extract should succeed\");\n let value: Value = serde_json::from_str(&snippet).expect(\"snippet is valid JSON\");\n\n // 所有凭据都不得出现在共享片段里\n let env = value.get(\"env\");\n for leaked in [\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_AUTH_TOKEN\",\n \"OPENROUTER_API_KEY\",\n \"GOOGLE_API_KEY\",\n \"OPENAI_API_KEY\",\n \"GEMINI_API_KEY\",\n \"AWS_ACCESS_KEY_ID\",\n \"AWS_SECRET_ACCESS_KEY\",\n \"AWS_SESSION_TOKEN\",\n \"GOOGLE_APPLICATION_CREDENTIALS\",\n \"AWS_BEARER_TOKEN_BEDROCK\",\n ] {\n assert!(\n env.and_then(|e| e.get(leaked)).is_none(),\n \"credential {leaked} must not leak into common config\"\n );\n }\n assert!(\n value.get(\"apiKey\").is_none() && value.get(\"api_key\").is_none(),\n \"top-level credentials must be stripped\"\n );\n\n // 端点/模型(provider-specific 非机密)也应剥掉\n assert!(env.and_then(|e| e.get(\"ANTHROPIC_BASE_URL\")).is_none());\n assert!(env.and_then(|e| e.get(\"ANTHROPIC_MODEL\")).is_none());\n\n // 可共享的非机密配置必须保留(含复数 _TOKENS 不被误剥)\n assert_eq!(\n env.and_then(|e| e.get(\"ENABLE_TOOL_SEARCH\"))\n .and_then(|v| v.as_str()),\n Some(\"true\")\n );\n assert_eq!(\n env.and_then(|e| e.get(\"CLAUDE_CODE_MAX_OUTPUT_TOKENS\"))\n .and_then(|v| v.as_str()),\n Some(\"8192\")\n );\n assert_eq!(value.get(\"theme\").and_then(|v| v.as_str()), Some(\"dark\"));\n assert_eq!(value.get(\"includeCoAuthoredBy\"), Some(&json!(false)));\n }\n\n #[test]\n fn validate_provider_settings_rejects_negative_cost_multiplier() {\n let mut provider = Provider::with_id(\n \"claude\".into(),\n \"Claude\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"token\",\n \"ANTHROPIC_BASE_URL\": \"https://claude.example\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n cost_multiplier: Some(\"-1\".to_string()),\n ..ProviderMeta::default()\n });\n\n let err = ProviderService::validate_provider_settings(&AppType::Claude, &provider)\n .expect_err(\"negative multiplier should be rejected\");\n assert!(matches!(\n err,\n AppError::Localized {\n key: \"error.invalidMultiplier\",\n ..\n }\n ));\n }\n\n #[test]\n fn extract_credentials_returns_expected_values() {\n let provider = Provider::with_id(\n \"claude\".into(),\n \"Claude\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"token\",\n \"ANTHROPIC_BASE_URL\": \"https://claude.example\"\n }\n }),\n None,\n );\n let (api_key, base_url) =\n ProviderService::extract_credentials(&provider, &AppType::Claude).unwrap();\n assert_eq!(api_key, \"token\");\n assert_eq!(base_url, \"https://claude.example\");\n }\n\n #[test]\n fn extract_codex_common_config_preserves_mcp_servers_base_url() {\n let config_toml = r#\"model_provider = \"azure\"\nmodel = \"gpt-4\"\ndisable_response_storage = true\n\n[model_providers.azure]\nname = \"Azure OpenAI\"\nbase_url = \"https://azure.example/v1\"\nwire_api = \"responses\"\n\n[mcp_servers.my_server]\nbase_url = \"http://localhost:8080\"\n\"#;\n\n let settings = json!({ \"config\": config_toml });\n let extracted = ProviderService::extract_codex_common_config(&settings)\n .expect(\"extract_codex_common_config should succeed\");\n\n assert!(\n !extracted\n .lines()\n .any(|line| line.trim_start().starts_with(\"model_provider\")),\n \"should remove top-level model_provider\"\n );\n assert!(\n !extracted\n .lines()\n .any(|line| line.trim_start().starts_with(\"model =\")),\n \"should remove top-level model\"\n );\n assert!(\n !extracted.contains(\"[model_providers\"),\n \"should remove entire model_providers table\"\n );\n assert!(\n extracted.contains(\"http://localhost:8080\"),\n \"should keep mcp_servers.* base_url\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_current_claude_provider_syncs_live_when_proxy_takeover_detected_without_backup()\n {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = AppState::new(db.clone());\n\n let original = Provider::with_id(\n \"p1\".into(),\n \"Claude A\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"token-a\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"model-a\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n db.save_provider(\"claude\", &original)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"p1\"))\n .expect(\"set local current provider\");\n\n db.update_proxy_config(ProxyConfig {\n live_takeover_active: true,\n listen_port: 0,\n ..Default::default()\n })\n .await\n .expect(\"update proxy config\");\n {\n let mut config = db\n .get_proxy_config_for_app(\"claude\")\n .await\n .expect(\"get app proxy config\");\n config.enabled = true;\n db.update_proxy_config_for_app(config)\n .await\n .expect(\"update app proxy config\");\n }\n\n write_json_file(\n &get_claude_settings_path(),\n &json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": \"PROXY_MANAGED\",\n \"ANTHROPIC_MODEL\": \"stale-model\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n )\n .expect(\"seed taken-over live file\");\n\n let proxy_info = state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy service\");\n\n let updated = Provider::with_id(\n \"p1\".into(),\n \"Claude A\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"token-updated\",\n \"ANTHROPIC_BASE_URL\": \"https://api.updated.example\",\n \"ANTHROPIC_MODEL\": \"model-updated\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n ProviderService::update(&state, AppType::Claude, None, updated.clone())\n .expect(\"update current provider\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored_provider = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get stored provider\")\n .expect(\"stored provider exists\");\n let expected_backup =\n serde_json::to_string(&stored_provider.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected_backup);\n\n let live: Value = read_json_file(&get_claude_settings_path()).expect(\"read live\");\n assert_eq!(\n live.get(\"permissions\"),\n updated.settings_config.get(\"permissions\"),\n \"provider edits should propagate into Claude live config during takeover\"\n );\n assert_eq!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"PROXY_MANAGED\"),\n \"takeover placeholder should stay intact\"\n );\n assert_eq!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_BASE_URL\"))\n .and_then(|v| v.as_str()),\n Some(format!(\"http://127.0.0.1:{}\", proxy_info.port).as_str()),\n \"proxy base URL should stay intact\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"model override should be removed in takeover live config\"\n );\n }\n\n #[cfg(any(target_os = \"macos\", windows))]\n #[tokio::test]\n #[serial]\n async fn update_current_claude_desktop_provider_syncs_profile_when_proxy_takeover_is_active() {\n let home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = AppState::new(db.clone());\n\n let mut original = Provider::with_id(\n \"p1\".into(),\n \"Desktop A\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"token-a\",\n \"ANTHROPIC_BASE_URL\": \"https://opencode.ai/zen/go\"\n }\n }),\n None,\n );\n original.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".into()),\n claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),\n claude_desktop_model_routes: std::collections::HashMap::from([(\n \"claude-sonnet-4-6\".into(),\n ClaudeDesktopModelRoute {\n model: \"deepseek-v4-flash\".into(),\n label_override: Some(\"DeepSeek V4 Flash\".into()),\n supports_1m: None,\n },\n )]),\n ..Default::default()\n });\n db.save_provider(\"claude-desktop\", &original)\n .expect(\"save provider\");\n db.set_current_provider(\"claude-desktop\", \"p1\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::ClaudeDesktop, Some(\"p1\"))\n .expect(\"set local current provider\");\n\n // Claude Desktop keeps backup state from takeover startup; this sentinel only\n // marks takeover as active so provider updates rewrite the 3P profile.\n db.save_live_backup(\"claude-desktop\", \"{}\")\n .await\n .expect(\"seed live backup\");\n {\n let mut config = db\n .get_proxy_config_for_app(\"claude-desktop\")\n .await\n .expect(\"get app proxy config\");\n config.enabled = true;\n db.update_proxy_config_for_app(config)\n .await\n .expect(\"update app proxy config\");\n }\n\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy service\");\n\n let mut updated = Provider::with_id(\n \"p1\".into(),\n \"Desktop A\".into(),\n json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"token-updated\",\n \"ANTHROPIC_BASE_URL\": \"https://opencode.ai/zen/go\"\n }\n }),\n None,\n );\n updated.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".into()),\n claude_desktop_mode: Some(ClaudeDesktopMode::Proxy),\n claude_desktop_model_routes: std::collections::HashMap::from([(\n \"claude-sonnet-4-6\".into(),\n ClaudeDesktopModelRoute {\n model: \"deepseek-v4-flash\".into(),\n label_override: Some(\"DeepSeek V4 Flash Updated\".into()),\n supports_1m: Some(true),\n },\n )]),\n ..Default::default()\n });\n\n ProviderService::update(&state, AppType::ClaudeDesktop, None, updated.clone())\n .expect(\"update current provider\");\n\n let backup = db\n .get_live_backup(\"claude-desktop\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n assert_eq!(\n backup.original_config, \"{}\",\n \"Claude Desktop provider edits should not rewrite takeover backup\"\n );\n\n let profile_path = claude_desktop_profile_path(home.dir.path());\n let profile: Value = read_json_file(&profile_path).expect(\"read desktop profile\");\n assert_eq!(\n profile[\"inferenceGatewayBaseUrl\"],\n json!(\"http://127.0.0.1:15721/claude-desktop\"),\n \"desktop profile should stay pointed at the local gateway during takeover\"\n );\n assert_eq!(profile[\"inferenceGatewayAuthScheme\"], json!(\"bearer\"));\n assert_eq!(\n profile[\"inferenceModels\"],\n json!([{ \"name\": \"claude-sonnet-4-6\", \"labelOverride\": \"DeepSeek V4 Flash Updated\", \"supports1m\": true }]),\n \"provider edits should propagate into the Claude Desktop 3P profile during takeover\"\n );\n }\n\n #[test]\n #[serial]\n fn rename_rejects_missing_original_provider() {\n with_test_home(|state, _| {\n let original = openclaw_provider(\"deepseek\");\n ProviderService::add(state, AppType::OpenClaw, original.clone(), false)\n .expect(\"seed db-only provider\");\n\n let mut renamed = original.clone();\n renamed.id = \"deepseek-copy\".to_string();\n\n let err = ProviderService::update(\n state,\n AppType::OpenClaw,\n Some(\"missing-provider\"),\n renamed,\n )\n .expect_err(\"stale originalId should be rejected\");\n\n assert!(\n err.to_string().contains(\"Original provider\"),\n \"expected missing original provider error, got {err:?}\"\n );\n assert!(\n state\n .db\n .get_provider_by_id(\"deepseek-copy\", AppType::OpenClaw.as_str())\n .expect(\"query renamed provider\")\n .is_none(),\n \"rename must not create a new row when originalId is stale\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn db_only_additive_update_survives_live_config_parse_errors() {\n with_test_home(|state, home| {\n let provider = openclaw_provider(\"deepseek\");\n ProviderService::add(state, AppType::OpenClaw, provider.clone(), false)\n .expect(\"seed db-only provider\");\n\n let stored = state\n .db\n .get_provider_by_id(\"deepseek\", AppType::OpenClaw.as_str())\n .expect(\"query stored provider\")\n .expect(\"provider should exist\");\n assert_eq!(\n stored\n .meta\n .as_ref()\n .and_then(|meta| meta.live_config_managed),\n Some(false),\n \"db-only provider should be marked as not live-managed\"\n );\n\n let openclaw_dir = home.join(\".openclaw\");\n fs::create_dir_all(&openclaw_dir).expect(\"create openclaw dir\");\n fs::write(openclaw_dir.join(\"openclaw.json\"), \"{ invalid json5\")\n .expect(\"write malformed config\");\n\n let mut updated = stored.clone();\n updated.name = \"DeepSeek Edited\".to_string();\n updated.meta.get_or_insert_with(ProviderMeta::default);\n\n ProviderService::update(state, AppType::OpenClaw, None, updated)\n .expect(\"db-only update should ignore live parse errors\");\n\n let saved = state\n .db\n .get_provider_by_id(\"deepseek\", AppType::OpenClaw.as_str())\n .expect(\"query updated provider\")\n .expect(\"updated provider should exist\");\n assert_eq!(saved.name, \"DeepSeek Edited\");\n });\n }\n\n #[test]\n #[serial]\n fn sync_current_provider_for_app_skips_db_only_opencode_provider() {\n with_test_home(|state, _| {\n let provider = opencode_provider(\"db-only-opencode\");\n ProviderService::add(state, AppType::OpenCode, provider.clone(), false)\n .expect(\"seed db-only opencode provider\");\n\n ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)\n .expect(\"sync additive opencode providers\");\n\n let live_providers = crate::opencode_config::get_providers()\n .expect(\"read opencode providers after sync\");\n assert!(\n !live_providers.contains_key(&provider.id),\n \"db-only opencode provider should not be written to live during sync\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn sync_current_provider_for_app_skips_db_only_openclaw_provider() {\n with_test_home(|state, _| {\n let provider = openclaw_provider(\"db-only-openclaw\");\n ProviderService::add(state, AppType::OpenClaw, provider.clone(), false)\n .expect(\"seed db-only openclaw provider\");\n\n ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw)\n .expect(\"sync additive openclaw providers\");\n\n let live_providers = crate::openclaw_config::get_providers()\n .expect(\"read openclaw providers after sync\");\n assert!(\n !live_providers.contains_key(&provider.id),\n \"db-only openclaw provider should not be written to live during sync\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn sync_current_provider_for_app_preserves_legacy_live_opencode_provider() {\n with_test_home(|state, _| {\n let provider = opencode_provider(\"legacy-opencode\");\n crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone())\n .expect(\"seed opencode live provider\");\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .expect(\"seed legacy opencode provider in db\");\n\n let mut updated = provider.clone();\n updated.settings_config[\"options\"][\"apiKey\"] = Value::String(\"updated-key\".to_string());\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &updated)\n .expect(\"update legacy opencode provider in db\");\n\n ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)\n .expect(\"sync legacy opencode provider\");\n\n let live_providers =\n crate::opencode_config::get_providers().expect(\"read opencode providers\");\n assert_eq!(\n live_providers\n .get(&provider.id)\n .and_then(|config| config.get(\"options\"))\n .and_then(|options| options.get(\"apiKey\")),\n Some(&Value::String(\"updated-key\".to_string())),\n \"legacy provider that already exists in live should still be synced\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn sync_current_provider_for_app_restores_legacy_opencode_provider_after_live_reset() {\n with_test_home(|state, _| {\n let provider = opencode_provider(\"legacy-opencode-reset\");\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .expect(\"seed legacy opencode provider in db\");\n\n ProviderService::sync_current_provider_for_app(state, AppType::OpenCode)\n .expect(\"sync legacy opencode provider after reset\");\n\n let live_providers =\n crate::opencode_config::get_providers().expect(\"read opencode providers\");\n assert!(\n live_providers.contains_key(&provider.id),\n \"legacy opencode provider should be restored when live config is reset\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn sync_current_provider_for_app_restores_legacy_openclaw_provider_after_live_reset() {\n with_test_home(|state, _| {\n let mut provider = openclaw_provider(\"legacy-openclaw-reset\");\n provider.settings_config[\"models\"] = json!([\n {\n \"id\": \"claude-sonnet-4\",\n \"name\": \"Claude Sonnet 4\"\n }\n ]);\n state\n .db\n .save_provider(AppType::OpenClaw.as_str(), &provider)\n .expect(\"seed legacy openclaw provider in db\");\n\n ProviderService::sync_current_provider_for_app(state, AppType::OpenClaw)\n .expect(\"sync legacy openclaw provider after reset\");\n\n let live_providers =\n crate::openclaw_config::get_providers().expect(\"read openclaw providers\");\n assert!(\n live_providers.contains_key(&provider.id),\n \"legacy openclaw provider should be restored when live config is reset\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn import_opencode_providers_from_live_marks_provider_as_live_managed() {\n with_test_home(|state, _| {\n let provider = opencode_provider(\"imported-opencode\");\n crate::opencode_config::set_provider(&provider.id, provider.settings_config.clone())\n .expect(\"seed opencode live provider\");\n\n let imported = import_opencode_providers_from_live(state)\n .expect(\"import opencode providers from live\");\n assert_eq!(imported, 1);\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .expect(\"query imported opencode provider\")\n .expect(\"imported opencode provider should exist\");\n assert_eq!(\n saved\n .meta\n .as_ref()\n .and_then(|meta| meta.live_config_managed),\n Some(true),\n \"providers imported from live should be treated as live-managed\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn import_openclaw_providers_from_live_marks_provider_as_live_managed() {\n with_test_home(|state, _| {\n let mut provider = openclaw_provider(\"imported-openclaw\");\n provider.settings_config[\"models\"] = json!([\n {\n \"id\": \"claude-sonnet-4\",\n \"name\": \"Claude Sonnet 4\"\n }\n ]);\n crate::openclaw_config::set_provider(&provider.id, provider.settings_config.clone())\n .expect(\"seed openclaw live provider\");\n\n let imported = import_openclaw_providers_from_live(state)\n .expect(\"import openclaw providers from live\");\n assert_eq!(imported, 1);\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenClaw.as_str())\n .expect(\"query imported openclaw provider\")\n .expect(\"imported openclaw provider should exist\");\n assert_eq!(\n saved\n .meta\n .as_ref()\n .and_then(|meta| meta.live_config_managed),\n Some(true),\n \"providers imported from live should be treated as live-managed\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn legacy_additive_provider_still_errors_on_live_config_parse_failure() {\n with_test_home(|state, home| {\n let provider = openclaw_provider(\"legacy-provider\");\n state\n .db\n .save_provider(AppType::OpenClaw.as_str(), &provider)\n .expect(\"seed legacy provider without live_config_managed marker\");\n\n let openclaw_dir = home.join(\".openclaw\");\n fs::create_dir_all(&openclaw_dir).expect(\"create openclaw dir\");\n fs::write(openclaw_dir.join(\"openclaw.json\"), \"{ invalid json5\")\n .expect(\"write malformed config\");\n\n let mut updated = provider.clone();\n updated.name = \"Legacy Edited\".to_string();\n\n let err = ProviderService::update(state, AppType::OpenClaw, None, updated)\n .expect_err(\"legacy providers should still surface live parse errors\");\n assert!(\n err.to_string().contains(\"Failed to parse OpenClaw config\"),\n \"expected parse error, got {err:?}\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn update_persists_non_current_omo_variants_in_database() {\n with_test_home(|state, _| {\n for category in [\"omo\", \"omo-slim\"] {\n let provider = opencode_omo_provider(&format!(\"{category}-provider\"), category);\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .unwrap_or_else(|err| panic!(\"seed {category} provider: {err}\"));\n\n let mut updated = provider.clone();\n updated.name = format!(\"Updated {category}\");\n updated.settings_config[\"agents\"][\"writer\"][\"model\"] =\n Value::String(format!(\"{category}-next-model\"));\n\n ProviderService::update(state, AppType::OpenCode, None, updated)\n .unwrap_or_else(|err| panic!(\"update {category} provider: {err}\"));\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .unwrap_or_else(|err| panic!(\"query updated {category} provider: {err}\"))\n .unwrap_or_else(|| panic!(\"{category} provider should exist\"));\n\n assert_eq!(saved.name, format!(\"Updated {category}\"));\n assert_eq!(\n saved.settings_config[\"agents\"][\"writer\"][\"model\"],\n Value::String(format!(\"{category}-next-model\")),\n \"{category} updates should persist in the database\"\n );\n }\n });\n }\n\n #[test]\n #[serial]\n fn update_current_omo_variant_rewrites_config_from_saved_provider() {\n with_test_home(|state, home| {\n for category in [\"omo\", \"omo-slim\"] {\n let provider = opencode_omo_provider(&format!(\"{category}-current\"), category);\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .unwrap_or_else(|err| panic!(\"seed current {category} provider: {err}\"));\n state\n .db\n .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, category)\n .unwrap_or_else(|err| panic!(\"set current {category} provider: {err}\"));\n\n let mut updated = provider.clone();\n updated.name = format!(\"Current {category} updated\");\n updated.settings_config[\"agents\"][\"writer\"][\"model\"] =\n Value::String(format!(\"{category}-saved-model\"));\n updated.settings_config[\"otherFields\"][\"theme\"] =\n Value::String(format!(\"{category}-light\"));\n\n ProviderService::update(state, AppType::OpenCode, None, updated)\n .unwrap_or_else(|err| panic!(\"update current {category} provider: {err}\"));\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .unwrap_or_else(|err| panic!(\"query current {category} provider: {err}\"))\n .unwrap_or_else(|| panic!(\"current {category} provider should exist\"));\n assert_eq!(saved.name, format!(\"Current {category} updated\"));\n\n let written = fs::read_to_string(omo_config_path(home, category))\n .unwrap_or_else(|err| panic!(\"read written {category} config: {err}\"));\n let written_json: Value = serde_json::from_str(&written)\n .unwrap_or_else(|err| panic!(\"parse written {category} config: {err}\"));\n\n assert_eq!(\n written_json[\"agents\"][\"writer\"][\"model\"],\n Value::String(format!(\"{category}-saved-model\")),\n \"{category} config should be written from the saved provider state\"\n );\n assert_eq!(\n written_json[\"theme\"],\n Value::String(format!(\"{category}-light\")),\n \"{category} top-level config should reflect updated otherFields\"\n );\n }\n });\n }\n\n #[test]\n #[serial]\n fn update_current_omo_variant_does_not_persist_database_when_file_write_fails() {\n with_test_home(|state, home| {\n let provider = opencode_omo_provider(\"omo-current\", \"omo\");\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .unwrap_or_else(|err| panic!(\"seed current omo provider: {err}\"));\n state\n .db\n .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, \"omo\")\n .unwrap_or_else(|err| panic!(\"set current omo provider: {err}\"));\n\n let config_dir = home.join(\".config\").join(\"opencode\");\n fs::create_dir_all(config_dir.parent().expect(\"config dir parent\"))\n .expect(\"create .config dir\");\n fs::write(&config_dir, \"not a directory\").expect(\"block opencode config dir\");\n\n let mut updated = provider.clone();\n updated.name = \"Current omo updated\".to_string();\n updated.settings_config[\"agents\"][\"writer\"][\"model\"] =\n Value::String(\"omo-saved-model\".to_string());\n\n ProviderService::update(state, AppType::OpenCode, None, updated)\n .expect_err(\"update should fail when current omo file write fails\");\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .unwrap_or_else(|err| panic!(\"query current omo provider: {err}\"))\n .unwrap_or_else(|| panic!(\"current omo provider should exist\"));\n\n assert_eq!(saved.name, provider.name);\n assert_eq!(\n saved.settings_config[\"agents\"][\"writer\"][\"model\"],\n provider.settings_config[\"agents\"][\"writer\"][\"model\"],\n \"database should remain unchanged when file write fails\"\n );\n });\n }\n\n #[test]\n #[serial]\n fn update_current_omo_variant_rolls_back_file_when_plugin_sync_fails() {\n with_test_home(|state, home| {\n let provider = opencode_omo_provider(\"omo-current\", \"omo\");\n state\n .db\n .save_provider(AppType::OpenCode.as_str(), &provider)\n .unwrap_or_else(|err| panic!(\"seed current omo provider: {err}\"));\n state\n .db\n .set_omo_provider_current(AppType::OpenCode.as_str(), &provider.id, \"omo\")\n .unwrap_or_else(|err| panic!(\"set current omo provider: {err}\"));\n\n let config_path = omo_config_path(home, \"omo\");\n fs::create_dir_all(config_path.parent().expect(\"omo config parent\"))\n .expect(\"create omo config dir\");\n let previous_content = serde_json::to_string_pretty(&json!({\n \"theme\": \"legacy-live-theme\",\n \"agents\": {\n \"writer\": {\n \"model\": \"legacy-live-model\"\n }\n },\n \"categories\": {\n \"default\": [\"writer\"]\n }\n }))\n .expect(\"serialize previous config\");\n fs::write(&config_path, &previous_content).expect(\"seed previous omo config\");\n\n let opencode_config_path = home.join(\".config\").join(\"opencode\").join(\"opencode.json\");\n fs::write(&opencode_config_path, \"{ invalid json\").expect(\"seed malformed opencode\");\n\n let mut updated = provider.clone();\n updated.name = \"Current omo updated\".to_string();\n updated.settings_config[\"agents\"][\"writer\"][\"model\"] =\n Value::String(\"omo-saved-model\".to_string());\n updated.settings_config[\"otherFields\"][\"theme\"] =\n Value::String(\"omo-light\".to_string());\n\n ProviderService::update(state, AppType::OpenCode, None, updated)\n .expect_err(\"update should fail when plugin sync fails\");\n\n let saved = state\n .db\n .get_provider_by_id(&provider.id, AppType::OpenCode.as_str())\n .unwrap_or_else(|err| panic!(\"query current omo provider: {err}\"))\n .unwrap_or_else(|| panic!(\"current omo provider should exist\"));\n\n assert_eq!(saved.name, provider.name);\n assert_eq!(\n saved.settings_config[\"agents\"][\"writer\"][\"model\"],\n provider.settings_config[\"agents\"][\"writer\"][\"model\"],\n \"database should remain unchanged when plugin sync fails\"\n );\n\n let written =\n fs::read_to_string(&config_path).expect(\"read rolled back omo config content\");\n assert_eq!(\n written, previous_content,\n \"OMO config should roll back to its previous on-disk contents\"\n );\n });\n }\n}\n\nimpl ProviderService {\n fn normalize_provider_if_claude(app_type: &AppType, provider: &mut Provider) {\n if matches!(app_type, AppType::Claude) {\n let mut v = provider.settings_config.clone();\n if normalize_claude_models_in_value(&mut v) {\n provider.settings_config = v;\n }\n }\n }\n\n /// Check whether a provider exists in live config, tolerating parse errors\n /// only for providers that are explicitly marked as DB-only.\n fn check_live_config_exists(\n app_type: &AppType,\n provider_id: &str,\n live_config_managed: Option<bool>,\n ) -> Result<bool, AppError> {\n if live_config_managed == Some(false) {\n Ok(provider_exists_in_live_config(app_type, provider_id).unwrap_or(false))\n } else {\n provider_exists_in_live_config(app_type, provider_id)\n }\n }\n\n fn provider_live_config_managed(provider: &Provider) -> Option<bool> {\n provider\n .meta\n .as_ref()\n .and_then(|meta| meta.live_config_managed)\n }\n\n fn set_provider_live_config_managed(provider: &mut Provider, managed: bool) {\n provider\n .meta\n .get_or_insert_with(Default::default)\n .live_config_managed = Some(managed);\n }\n\n fn normalize_usage_script_credential_overrides(app_type: &AppType, provider: &mut Provider) {\n let current_credentials = provider.resolve_usage_credentials(app_type);\n\n let Some(usage_script) = provider\n .meta\n .as_mut()\n .and_then(|meta| meta.usage_script.as_mut())\n else {\n return;\n };\n\n if usage_script.template_type.as_deref() == Some(\"token_plan\") {\n return;\n }\n\n if usage_script.api_key.as_deref().is_some_and(|api_key| {\n Self::should_clear_usage_api_key_override(api_key, &current_credentials)\n }) {\n usage_script.api_key = None;\n }\n\n if usage_script.base_url.as_deref().is_some_and(|base_url| {\n Self::should_clear_usage_base_url_override(base_url, &current_credentials)\n }) {\n usage_script.base_url = None;\n }\n }\n\n fn should_clear_usage_api_key_override(\n script_api_key: &str,\n current_credentials: &(String, String),\n ) -> bool {\n let candidate = script_api_key.trim();\n if candidate.is_empty() {\n return true;\n }\n\n let matches_provider_key = |api_key: &str| {\n let api_key = api_key.trim();\n !api_key.is_empty() && api_key == candidate\n };\n\n matches_provider_key(&current_credentials.1)\n }\n\n fn should_clear_usage_base_url_override(\n script_base_url: &str,\n current_credentials: &(String, String),\n ) -> bool {\n let candidate = Self::normalize_usage_base_url_for_compare(script_base_url);\n if candidate.is_empty() {\n return true;\n }\n\n let matches_provider_base_url = |base_url: &str| {\n let base_url = Self::normalize_usage_base_url_for_compare(base_url);\n !base_url.is_empty() && base_url == candidate\n };\n\n matches_provider_base_url(&current_credentials.0)\n }\n\n fn normalize_usage_base_url_for_compare(base_url: &str) -> String {\n base_url.trim().trim_end_matches('/').to_string()\n }\n\n /// List all providers for an app type\n pub fn list(\n state: &AppState,\n app_type: AppType,\n ) -> Result<IndexMap<String, Provider>, AppError> {\n state.db.get_all_providers(app_type.as_str())\n }\n\n /// Get current provider ID\n ///\n /// 使用有效的当前供应商 ID(验证过存在性)。\n /// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。\n /// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。\n ///\n /// 对于累加模式应用(OpenCode, OpenClaw),不存在\"当前供应商\"概念,直接返回空字符串。\n pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {\n // Additive mode apps have no \"current\" provider concept\n if app_type.is_additive_mode() {\n return Ok(String::new());\n }\n crate::settings::get_effective_current_provider(&state.db, &app_type)\n .map(|opt| opt.unwrap_or_default())\n }\n\n /// Add a new provider\n pub fn add(\n state: &AppState,\n app_type: AppType,\n provider: Provider,\n add_to_live: bool,\n ) -> Result<bool, AppError> {\n let mut provider = provider;\n // Normalize Claude model keys\n Self::normalize_provider_if_claude(&app_type, &mut provider);\n Self::validate_provider_settings(&app_type, &provider)?;\n normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;\n Self::normalize_usage_script_credential_overrides(&app_type, &mut provider);\n if app_type.is_additive_mode() {\n Self::set_provider_live_config_managed(&mut provider, add_to_live);\n }\n\n // Save to database\n state.db.save_provider(app_type.as_str(), &provider)?;\n\n // Additive mode apps (OpenCode, OpenClaw): optionally write to live config.\n if app_type.is_additive_mode() {\n // OMO / OMO Slim providers use exclusive mode and write to dedicated config file.\n if matches!(app_type, AppType::OpenCode)\n && matches!(provider.category.as_deref(), Some(\"omo\") | Some(\"omo-slim\"))\n {\n // Do not auto-enable newly added OMO / OMO Slim providers.\n // Users must explicitly switch/apply an OMO provider to activate it.\n return Ok(true);\n }\n if !add_to_live {\n return Ok(true);\n }\n write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n return Ok(true);\n }\n\n // For other apps: Check if sync is needed (if this is current provider, or no current provider)\n let current = state.db.get_current_provider(app_type.as_str())?;\n if current.is_none() {\n // No current provider, set as current and sync\n state\n .db\n .set_current_provider(app_type.as_str(), &provider.id)?;\n write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n }\n\n Ok(true)\n }\n\n /// Update a provider\n pub fn update(\n state: &AppState,\n app_type: AppType,\n original_id: Option<&str>,\n provider: Provider,\n ) -> Result<bool, AppError> {\n let mut provider = provider;\n let original_id = original_id.unwrap_or(provider.id.as_str()).to_string();\n let provider_id_changed = original_id != provider.id;\n let existing_provider = state\n .db\n .get_provider_by_id(&original_id, app_type.as_str())?;\n // Normalize Claude model keys\n Self::normalize_provider_if_claude(&app_type, &mut provider);\n Self::validate_provider_settings(&app_type, &provider)?;\n normalize_provider_common_config_for_storage(state.db.as_ref(), &app_type, &mut provider)?;\n Self::normalize_usage_script_credential_overrides(&app_type, &mut provider);\n\n if provider_id_changed {\n if !app_type.is_additive_mode() {\n return Err(AppError::Message(\n \"Only additive-mode providers support changing provider key\".to_string(),\n ));\n }\n\n let Some(existing_provider) = existing_provider else {\n return Err(AppError::Message(format!(\n \"Original provider '{}' does not exist in app '{}'\",\n original_id,\n app_type.as_str()\n )));\n };\n\n // OMO / OMO Slim providers are activated via a dedicated current-state mechanism\n // (set_omo_provider_current) that is NOT captured by provider_exists_in_live_config,\n // which only checks opencode.json. A rename would orphan that current-state marker\n // and silently break subsequent OMO file syncs. Block it unconditionally.\n if matches!(app_type, AppType::OpenCode)\n && matches!(\n existing_provider.category.as_deref(),\n Some(\"omo\") | Some(\"omo-slim\")\n )\n {\n return Err(AppError::Message(\n \"Provider key cannot be changed for OMO/OMO Slim providers\".to_string(),\n ));\n }\n\n let original_in_live = Self::check_live_config_exists(\n &app_type,\n &original_id,\n Self::provider_live_config_managed(&existing_provider),\n )?;\n if original_in_live {\n return Err(AppError::Message(\n \"Provider key cannot be changed after the provider has been added to the app config\"\n .to_string(),\n ));\n }\n\n let next_id_in_live = Self::check_live_config_exists(\n &app_type,\n &provider.id,\n Self::provider_live_config_managed(&existing_provider),\n )?;\n if state\n .db\n .get_provider_by_id(&provider.id, app_type.as_str())?\n .is_some()\n || next_id_in_live\n {\n return Err(AppError::Message(format!(\n \"Provider '{}' already exists in app '{}'\",\n provider.id,\n app_type.as_str()\n )));\n }\n\n Self::set_provider_live_config_managed(&mut provider, false);\n state.db.save_provider(app_type.as_str(), &provider)?;\n state.db.delete_provider(app_type.as_str(), &original_id)?;\n\n if crate::settings::get_current_provider(&app_type).as_deref() == Some(&original_id) {\n crate::settings::set_current_provider(&app_type, Some(provider.id.as_str()))?;\n }\n\n return Ok(true);\n }\n\n // Additive mode apps (OpenCode, OpenClaw): only sync to live when the provider\n // already exists in live config. Editing a DB-only provider must not auto-add it.\n if app_type.is_additive_mode() {\n let omo_variant = if matches!(app_type, AppType::OpenCode) {\n match provider.category.as_deref() {\n Some(\"omo\") => Some(&crate::services::omo::STANDARD),\n Some(\"omo-slim\") => Some(&crate::services::omo::SLIM),\n _ => None,\n }\n } else {\n None\n };\n if let Some(variant) = omo_variant {\n let is_current = state.db.is_omo_provider_current(\n app_type.as_str(),\n &provider.id,\n variant.category,\n )?;\n if is_current {\n crate::services::OmoService::write_provider_config_to_file(&provider, variant)?;\n }\n if let Err(err) = state.db.save_provider(app_type.as_str(), &provider) {\n if is_current {\n if let Err(rollback_err) =\n crate::services::OmoService::write_config_to_file(state, variant)\n {\n log::warn!(\n \"Failed to roll back {} config after DB save error: {}\",\n variant.label,\n rollback_err\n );\n }\n }\n return Err(err);\n }\n return Ok(true);\n }\n let live_config_managed = Self::check_live_config_exists(\n &app_type,\n &provider.id,\n Self::provider_live_config_managed(&provider).or_else(|| {\n existing_provider\n .as_ref()\n .and_then(Self::provider_live_config_managed)\n }),\n )?;\n Self::set_provider_live_config_managed(&mut provider, live_config_managed);\n\n // Save to database after live-config presence is resolved so parse errors\n // do not report failure after already mutating DB state.\n state.db.save_provider(app_type.as_str(), &provider)?;\n\n if !live_config_managed {\n return Ok(true);\n }\n write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n return Ok(true);\n }\n\n // Save to database\n state.db.save_provider(app_type.as_str(), &provider)?;\n\n // For other apps: Check if this is current provider (use effective current, not just DB)\n let effective_current =\n crate::settings::get_effective_current_provider(&state.db, &app_type)?;\n let is_current = effective_current.as_deref() == Some(provider.id.as_str());\n\n if is_current {\n // 如果 Claude 代理接管处于激活状态,并且代理服务正在运行:\n // - 不直接走普通 Live 写入逻辑\n // - 改为更新 Live 备份,并在 Claude 下同步代理安全的 Live 配置\n let has_live_backup =\n futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))\n .ok()\n .flatten()\n .is_some();\n let live_taken_over = state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&app_type);\n // Backup or live placeholders mean the live file is currently owned\n // by proxy takeover, including the short activation window before\n // proxy_config.enabled is committed.\n let should_sync_via_proxy = has_live_backup || live_taken_over;\n\n if should_sync_via_proxy {\n if matches!(app_type, AppType::ClaudeDesktop) {\n write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n } else {\n futures::executor::block_on(\n state\n .proxy_service\n .update_live_backup_from_provider(app_type.as_str(), &provider),\n )\n .map_err(|e| AppError::Message(format!(\"更新 Live 备份失败: {e}\")))?;\n }\n\n if matches!(app_type, AppType::Claude)\n && futures::executor::block_on(state.proxy_service.is_running())\n {\n futures::executor::block_on(\n state\n .proxy_service\n .sync_claude_live_from_provider_while_proxy_active(&provider),\n )\n .map_err(|e| AppError::Message(format!(\"同步 Claude Live 配置失败: {e}\")))?;\n }\n } else {\n write_live_with_common_config(state.db.as_ref(), &app_type, &provider)?;\n // Sync MCP\n McpService::sync_all_enabled(state)?;\n }\n }\n\n Ok(true)\n }\n\n /// Delete a provider\n ///\n /// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。\n /// 对于累加模式应用(OpenCode, OpenClaw),可以随时删除任意供应商,同时从 live 配置中移除。\n pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {\n // Additive mode apps - no current provider concept\n if app_type.is_additive_mode() {\n // Single DB read shared across all additive-mode sub-paths below.\n let existing = state.db.get_provider_by_id(id, app_type.as_str())?;\n\n if matches!(app_type, AppType::OpenCode) {\n let provider_category = existing.as_ref().and_then(|p| p.category.clone());\n let omo_variant = match provider_category.as_deref() {\n Some(\"omo\") => Some(&crate::services::omo::STANDARD),\n Some(\"omo-slim\") => Some(&crate::services::omo::SLIM),\n _ => None,\n };\n if let Some(variant) = omo_variant {\n let was_current = state.db.is_omo_provider_current(\n app_type.as_str(),\n id,\n variant.category,\n )?;\n state.db.delete_provider(app_type.as_str(), id)?;\n if was_current {\n crate::services::OmoService::delete_config_file(variant)?;\n }\n return Ok(());\n }\n }\n\n // Non-OMO path for both OpenCode and OpenClaw:\n // remove from live first (atomicity), then DB.\n //\n // Use check_live_config_exists rather than trusting the flag alone: the flag\n // can be stale (Some(false) for a provider that was written to live before the\n // live_config_managed flip was introduced). check_live_config_exists reads the\n // actual file when the flag is Some(false), so it handles historical data correctly.\n let live_managed = existing\n .as_ref()\n .and_then(Self::provider_live_config_managed);\n if Self::check_live_config_exists(&app_type, id, live_managed)? {\n match app_type {\n AppType::OpenCode => remove_opencode_provider_from_live(id)?,\n AppType::OpenClaw => remove_openclaw_provider_from_live(id)?,\n AppType::Hermes => remove_hermes_provider_from_live(id)?,\n _ => {}\n }\n }\n state.db.delete_provider(app_type.as_str(), id)?;\n return Ok(());\n }\n\n // For other apps: Check both local settings and database\n let local_current = crate::settings::get_current_provider(&app_type);\n let db_current = state.db.get_current_provider(app_type.as_str())?;\n\n if local_current.as_deref() == Some(id) || db_current.as_deref() == Some(id) {\n return Err(AppError::Message(\n \"无法删除当前正在使用的供应商\".to_string(),\n ));\n }\n\n state.db.delete_provider(app_type.as_str(), id)\n }\n\n /// Remove provider from live config only (for additive mode apps like OpenCode, OpenClaw)\n ///\n /// Does NOT delete from database - provider remains in the list.\n /// This is used when user wants to \"remove\" a provider from active config\n /// but keep it available for future use.\n pub fn remove_from_live_config(\n state: &AppState,\n app_type: AppType,\n id: &str,\n ) -> Result<(), AppError> {\n match app_type {\n AppType::OpenCode => {\n let provider_category = state\n .db\n .get_provider_by_id(id, app_type.as_str())?\n .and_then(|p| p.category);\n\n let omo_variant = match provider_category.as_deref() {\n Some(\"omo\") => Some(&crate::services::omo::STANDARD),\n Some(\"omo-slim\") => Some(&crate::services::omo::SLIM),\n _ => None,\n };\n if let Some(variant) = omo_variant {\n state\n .db\n .clear_omo_provider_current(app_type.as_str(), id, variant.category)?;\n let still_has_current = state\n .db\n .get_current_omo_provider(\"opencode\", variant.category)?\n .is_some();\n if still_has_current {\n crate::services::OmoService::write_config_to_file(state, variant)?;\n } else {\n crate::services::OmoService::delete_config_file(variant)?;\n }\n } else {\n remove_opencode_provider_from_live(id)?;\n }\n }\n AppType::OpenClaw => {\n remove_openclaw_provider_from_live(id)?;\n }\n AppType::Hermes => {\n remove_hermes_provider_from_live(id)?;\n }\n _ => {\n return Err(AppError::Message(format!(\n \"App {} does not support remove from live config\",\n app_type.as_str()\n )));\n }\n }\n\n if let Some(mut provider) = state.db.get_provider_by_id(id, app_type.as_str())? {\n Self::set_provider_live_config_managed(&mut provider, false);\n state.db.save_provider(app_type.as_str(), &provider)?;\n }\n\n Ok(())\n }\n\n /// Switch to a provider\n ///\n /// Switch flow:\n /// 1. Validate target provider exists\n /// 2. Check if proxy takeover mode is active AND proxy server is running\n /// 3. If takeover mode active: hot-switch proxy target and refresh proxy-safe Live labels\n /// 4. If normal mode:\n /// a. **Backfill mechanism**: Backfill current live config to current provider\n /// b. Update local settings current_provider_xxx (device-level)\n /// c. Update database is_current (as default for new devices)\n /// d. Write target provider config to live files\n /// e. Sync MCP configuration\n pub fn switch(state: &AppState, app_type: AppType, id: &str) -> Result<SwitchResult, AppError> {\n // Check if provider exists\n let providers = state.db.get_all_providers(app_type.as_str())?;\n let _provider = providers\n .get(id)\n .ok_or_else(|| AppError::Message(format!(\"供应商 {id} 不存在\")))?;\n\n // OMO providers are switched through their own exclusive path.\n if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some(\"omo\") {\n return Self::switch_normal(state, app_type, id, &providers);\n }\n\n // OMO Slim providers are switched through their own exclusive path.\n if matches!(app_type, AppType::OpenCode)\n && _provider.category.as_deref() == Some(\"omo-slim\")\n {\n return Self::switch_normal(state, app_type, id, &providers);\n }\n\n if matches!(app_type, AppType::ClaudeDesktop) {\n return Self::switch_normal(state, app_type, id, &providers);\n }\n\n // Provider switches and takeover toggles both mutate live config and the\n // restore backup. Serialize them per app, then decide from the locked\n // current state so a just-started takeover cannot be overwritten by a\n // normal live write.\n let _switch_guard =\n if matches!(app_type, AppType::Claude | AppType::Codex | AppType::Gemini) {\n Some(futures::executor::block_on(\n state.proxy_service.lock_switch_for_app(app_type.as_str()),\n ))\n } else {\n None\n };\n\n // Backup or live placeholders mean the live file is owned by proxy\n // takeover, even if the proxy server is temporarily stopped or is in the\n // activation window before enabled=true is committed.\n let is_app_taken_over =\n futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))\n .ok()\n .flatten()\n .is_some();\n let live_taken_over = state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&app_type);\n\n let should_hot_switch = is_app_taken_over || live_taken_over;\n\n // Block switching to official providers when proxy takeover is active.\n // Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans.\n if should_hot_switch && _provider.category.as_deref() == Some(\"official\") {\n return Err(AppError::localized(\n \"switch.official_blocked_by_proxy\",\n \"代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。\",\n \"Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.\",\n ));\n }\n\n if should_hot_switch {\n // Proxy takeover mode: hot-switch without restoring upstream Live config.\n // The proxy layer may still refresh proxy-safe Live fields so client labels\n // follow the selected provider while endpoints remain local.\n log::info!(\n \"代理接管模式:热切换 {} 的目标供应商为 {}\",\n app_type.as_str(),\n id\n );\n\n futures::executor::block_on(\n state\n .proxy_service\n .hot_switch_provider_inner(app_type.as_str(), id),\n )\n .map_err(|e| AppError::Message(format!(\"热切换失败: {e}\")))?;\n\n // The proxy server will route requests to the new provider via is_current.\n // MCP sync is intentionally skipped while Live config is owned by takeover.\n return Ok(SwitchResult::default());\n }\n\n // Normal mode: full switch with Live config write\n Self::switch_normal(state, app_type, id, &providers)\n }\n\n /// Normal switch flow (non-proxy mode)\n fn switch_normal(\n state: &AppState,\n app_type: AppType,\n id: &str,\n providers: &indexmap::IndexMap<String, Provider>,\n ) -> Result<SwitchResult, AppError> {\n let provider = providers\n .get(id)\n .ok_or_else(|| AppError::Message(format!(\"供应商 {id} 不存在\")))?;\n\n // OMO ↔ OMO Slim are mutually exclusive; activating one removes the other's config file.\n if matches!(app_type, AppType::OpenCode) {\n let omo_pair = match provider.category.as_deref() {\n Some(\"omo\") => Some((&crate::services::omo::STANDARD, &crate::services::omo::SLIM)),\n Some(\"omo-slim\") => {\n Some((&crate::services::omo::SLIM, &crate::services::omo::STANDARD))\n }\n _ => None,\n };\n if let Some((enable, disable)) = omo_pair {\n state\n .db\n .set_omo_provider_current(app_type.as_str(), id, enable.category)?;\n crate::services::OmoService::write_config_to_file(state, enable)?;\n let _ = crate::services::OmoService::delete_config_file(disable);\n return Ok(SwitchResult::default());\n }\n }\n\n let mut result = SwitchResult::default();\n\n // Backfill: Backfill current live config to current provider\n // Use effective current provider (validated existence) to ensure backfill targets valid provider\n let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;\n\n if let Some(current_id) = current_id {\n if current_id != id {\n // Additive mode apps - all providers coexist in the same file,\n // no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)\n if !app_type.is_additive_mode() {\n // Only backfill when switching to a different provider\n if let Ok(live_config) = read_live_settings(app_type.clone()) {\n if let Some(mut current_provider) = providers.get(&current_id).cloned() {\n // 切走前先把 live 里的可共享改动(含用户直接在应用内\n // 装插件/加 hook/改偏好)同步进通用配置片段,再做剥离回填。\n // 详见 sync_common_config_snippet_from_live 的文档。\n Self::sync_common_config_snippet_from_live(\n state,\n &app_type,\n &current_provider,\n &live_config,\n &mut result,\n );\n\n current_provider.settings_config =\n strip_common_config_from_live_settings(\n state.db.as_ref(),\n &app_type,\n &current_provider,\n live_config,\n );\n if let Err(e) =\n state.db.save_provider(app_type.as_str(), &current_provider)\n {\n log::warn!(\"Backfill failed: {e}\");\n result\n .warnings\n .push(format!(\"backfill_failed:{current_id}\"));\n }\n }\n }\n }\n }\n }\n\n // Additive mode apps skip setting is_current (no such concept)\n if !app_type.is_additive_mode() {\n // Update local settings (device-level, takes priority)\n crate::settings::set_current_provider(&app_type, Some(id))?;\n\n // Update database is_current (as default for new devices)\n state.db.set_current_provider(app_type.as_str(), id)?;\n }\n\n // Sync to live (write_gemini_live handles security flag internally for Gemini)\n write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;\n\n // Hermes is additive, so \"switching\" doesn't overwrite a live config file\n // — we instead update the top-level `model:` section to point at this\n // provider's first declared model. Without this, clicking \"switch\" would\n // only shuffle entries in custom_providers[] while Hermes keeps using\n // whatever `model.provider` was set before.\n if matches!(app_type, AppType::Hermes) {\n if let Err(e) =\n crate::hermes_config::apply_switch_defaults(&provider.id, &provider.settings_config)\n {\n log::warn!(\n \"Failed to update Hermes model defaults after switching to '{}': {e}\",\n provider.id\n );\n result\n .warnings\n .push(format!(\"hermes_model_defaults_failed:{}\", provider.id));\n }\n }\n\n // For additive-mode providers that were DB-only (live_config_managed == Some(false)),\n // flip the flag to true now that the provider has been successfully written to the live\n // file. This ensures sync_all_providers_to_live() will include it on future syncs.\n //\n // If persisting the marker fails, roll back the just-written live config so we don't leave\n // the provider in a silent inconsistent state (present in live, but still marked DB-only).\n if app_type.is_additive_mode() && Self::provider_live_config_managed(provider) != Some(true)\n {\n let mut updated = provider.clone();\n Self::set_provider_live_config_managed(&mut updated, true);\n if let Err(e) = state.db.save_provider(app_type.as_str(), &updated) {\n let rollback_result = match app_type {\n AppType::OpenCode => remove_opencode_provider_from_live(&provider.id),\n AppType::OpenClaw => remove_openclaw_provider_from_live(&provider.id),\n AppType::Hermes => remove_hermes_provider_from_live(&provider.id),\n _ => Ok(()),\n };\n\n match rollback_result {\n Ok(()) => {\n return Err(AppError::Message(format!(\n \"Failed to persist live_config_managed for '{}' after writing live config; live changes were rolled back: {e}\",\n provider.id\n )));\n }\n Err(rollback_err) => {\n return Err(AppError::Message(format!(\n \"Failed to persist live_config_managed for '{}' after writing live config: {e}; additionally failed to roll back live config: {rollback_err}\",\n provider.id\n )));\n }\n }\n }\n }\n\n // Sync MCP\n McpService::sync_all_enabled(state)?;\n\n Ok(result)\n }\n\n /// Sync current provider to live configuration (re-export)\n pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {\n sync_current_to_live(state)\n }\n\n pub fn sync_current_provider_for_app(\n state: &AppState,\n app_type: AppType,\n ) -> Result<(), AppError> {\n if app_type.is_additive_mode() {\n return sync_current_provider_for_app_to_live(state, &app_type);\n }\n\n let current_id =\n match crate::settings::get_effective_current_provider(&state.db, &app_type)? {\n Some(id) => id,\n None => return Ok(()),\n };\n\n let providers = state.db.get_all_providers(app_type.as_str())?;\n let Some(provider) = providers.get(&current_id) else {\n return Ok(());\n };\n\n let has_live_backup =\n futures::executor::block_on(state.db.get_live_backup(app_type.as_str()))\n .ok()\n .flatten()\n .is_some();\n\n let live_taken_over = state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&app_type);\n\n // See the save path above: backup/placeholders are the ownership signal\n // here, not just proxy_config.enabled.\n if has_live_backup || live_taken_over {\n if matches!(app_type, AppType::ClaudeDesktop) {\n write_live_with_common_config(state.db.as_ref(), &app_type, provider)?;\n return Ok(());\n }\n\n futures::executor::block_on(\n state\n .proxy_service\n .update_live_backup_from_provider(app_type.as_str(), provider),\n )\n .map_err(|e| AppError::Message(format!(\"更新 Live 备份失败: {e}\")))?;\n return Ok(());\n }\n\n sync_current_provider_for_app_to_live(state, &app_type)\n }\n\n pub fn migrate_legacy_common_config_usage(\n state: &AppState,\n app_type: AppType,\n legacy_snippet: &str,\n ) -> Result<(), AppError> {\n if app_type.is_additive_mode() || legacy_snippet.trim().is_empty() {\n return Ok(());\n }\n\n let providers = state.db.get_all_providers(app_type.as_str())?;\n\n for provider in providers.values() {\n if provider\n .meta\n .as_ref()\n .and_then(|meta| meta.common_config_enabled)\n .is_some()\n {\n continue;\n }\n\n if !live::provider_uses_common_config(&app_type, provider, Some(legacy_snippet)) {\n continue;\n }\n\n let mut updated_provider = provider.clone();\n updated_provider\n .meta\n .get_or_insert_with(Default::default)\n .common_config_enabled = Some(true);\n\n match live::remove_common_config_from_settings(\n &app_type,\n &updated_provider.settings_config,\n legacy_snippet,\n ) {\n Ok(settings) => updated_provider.settings_config = settings,\n Err(err) => {\n log::warn!(\n \"Failed to normalize legacy common config for {} provider '{}': {err}\",\n app_type.as_str(),\n updated_provider.id\n );\n }\n }\n\n state\n .db\n .save_provider(app_type.as_str(), &updated_provider)?;\n }\n\n Ok(())\n }\n\n pub fn migrate_legacy_common_config_usage_if_needed(\n state: &AppState,\n app_type: AppType,\n ) -> Result<(), AppError> {\n if app_type.is_additive_mode() {\n return Ok(());\n }\n\n let Some(snippet) = state.db.get_config_snippet(app_type.as_str())? else {\n return Ok(());\n };\n\n if snippet.trim().is_empty() {\n return Ok(());\n }\n\n Self::migrate_legacy_common_config_usage(state, app_type, &snippet)\n }\n\n /// 切走某供应商前,把它 live 配置里的可共享部分重新提取并**整体替换**到\n /// 通用配置片段,使在 live 应用里直接做的改动不会因切换而丢失。\n ///\n /// 采用\"整体重提取 + 替换\"而非\"只合并新增\",是为了同时覆盖三种情况:\n /// - **新增**:用户直接在应用里装了插件、加了 hook、改了 env/主题/权限等共享\n /// 偏好,被捕获进通用配置,切到别的供应商也带得过去;\n /// - **删除**:被删掉的键不在新提取结果里,于是从片段里消失、下次切换不会被\n /// 重新注入——否则会出现\"插件怎么删也删不掉\"的反直觉 bug;\n /// - **密钥安全**:提取器已剥掉 auth / model / endpoint,密钥永不进共享片段。\n ///\n /// 之所以\"整体替换\"是安全的:每次写 live 都会把当前片段合并进去,所以切走时\n /// 读到的 live 一定是\"片段 + 本地改动\"的超集,重提取只会丢掉用户真正删掉的键,\n /// 不会误删其它供应商共享的内容。\n ///\n /// **作用域**:仅 Claude。Codex 的 live 是 TOML 且端点藏在 `[model_providers]`\n /// 表里(现有提取器不剥),自动同步会泄漏端点并与 modelCatalog / 统一会话桶 /\n /// auth 还原逻辑冲突;Gemini 暂未纳入。两者如需支持应各自单独验证后再加。\n ///\n /// 仅对**显式勾选\"写入通用配置\"**(`meta.common_config_enabled == Some(true)`)的\n /// 供应商生效;用户**显式清空**过片段(`_cleared`)时跳过,避免把用户主动清掉的\n /// 配置又塞回来。所有失败均为非致命,只记 warning,绝不阻断切换。\n fn sync_common_config_snippet_from_live(\n state: &AppState,\n app_type: &AppType,\n provider: &Provider,\n live_config: &Value,\n result: &mut SwitchResult,\n ) {\n // 作用域限定 Claude(见函数文档)。\n if !matches!(app_type, AppType::Claude) {\n return;\n }\n\n let opted_in = provider\n .meta\n .as_ref()\n .and_then(|meta| meta.common_config_enabled)\n == Some(true);\n if !opted_in {\n return;\n }\n\n match state.db.is_config_snippet_cleared(app_type.as_str()) {\n Ok(true) => return, // 用户显式清空过通用配置,尊重其选择,不再自动塞回\n Ok(false) => {}\n Err(err) => {\n log::warn!(\n \"Failed to read common config cleared flag for {}: {err}\",\n app_type.as_str()\n );\n return;\n }\n }\n\n let new_snippet = match Self::extract_common_config_snippet_from_settings(\n app_type.clone(),\n live_config,\n ) {\n Ok(snippet) => snippet,\n Err(err) => {\n log::warn!(\n \"Failed to extract common config from live for {} provider '{}': {err}\",\n app_type.as_str(),\n provider.id\n );\n return;\n }\n };\n\n // 未变化则跳过,避免无谓写库(不切 live 配置时这是常态路径)。\n let current = state\n .db\n .get_config_snippet(app_type.as_str())\n .ok()\n .flatten();\n if current.as_deref() == Some(new_snippet.as_str()) {\n return;\n }\n\n if let Err(err) = state\n .db\n .set_config_snippet(app_type.as_str(), Some(new_snippet))\n {\n log::warn!(\n \"Failed to persist synced common config for {} provider '{}': {err}\",\n app_type.as_str(),\n provider.id\n );\n result\n .warnings\n .push(format!(\"common_config_sync_failed:{}\", provider.id));\n }\n }\n\n /// Extract common config snippet from current provider\n ///\n /// Extracts the current provider's configuration and removes provider-specific fields\n /// (API keys, model settings, endpoints) to create a reusable common config snippet.\n pub fn extract_common_config_snippet(\n state: &AppState,\n app_type: AppType,\n ) -> Result<String, AppError> {\n // Get current provider\n let current_id = Self::current(state, app_type.clone())?;\n if current_id.is_empty() {\n return Err(AppError::Message(\"No current provider\".to_string()));\n }\n\n let providers = state.db.get_all_providers(app_type.as_str())?;\n let provider = providers\n .get(&current_id)\n .ok_or_else(|| AppError::Message(format!(\"Provider {current_id} not found\")))?;\n\n match app_type {\n AppType::Claude => Self::extract_claude_common_config(&provider.settings_config),\n AppType::ClaudeDesktop => Ok(String::new()),\n AppType::Codex => Self::extract_codex_common_config(&provider.settings_config),\n AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),\n AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),\n AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),\n AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets\n }\n }\n\n /// Extract common config snippet from a config value (e.g. editor content).\n pub fn extract_common_config_snippet_from_settings(\n app_type: AppType,\n settings_config: &Value,\n ) -> Result<String, AppError> {\n match app_type {\n AppType::Claude => Self::extract_claude_common_config(settings_config),\n AppType::ClaudeDesktop => Ok(String::new()),\n AppType::Codex => Self::extract_codex_common_config(settings_config),\n AppType::Gemini => Self::extract_gemini_common_config(settings_config),\n AppType::OpenCode => Self::extract_opencode_common_config(settings_config),\n AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),\n AppType::Hermes => Ok(String::new()), // Hermes doesn't use common config snippets\n }\n }\n\n /// 判断一个 env / 顶层配置键名是否为凭据/机密:凡命中一律不得写入共享的\n /// 通用配置片段。**故意从严**——多剥一个非机密键只是它不被共享(可恢复的小\n /// 不便),漏剥一个凭据则会把密钥注入到每个供应商(不可恢复的泄漏)。因此用\n /// 模式匹配覆盖整类,而非枚举具体名字(枚举永远会漏掉下一个 `*_API_KEY`)。\n ///\n /// 覆盖:Anthropic / OpenRouter / Google / OpenAI / Gemini 等 `*_API_KEY`\n /// (Claude provider 的凭据见 `Provider::resolve_usage_credentials`,确实支持\n /// `OPENROUTER_API_KEY` / `GOOGLE_API_KEY` 等回退)、各类 `*_AUTH_TOKEN` /\n /// 单数 `*_TOKEN`、AWS Bedrock / Vertex 凭据、以及通用 secret / password /\n /// 私钥命名。\n fn is_sensitive_config_key(name: &str) -> bool {\n let upper = name.to_ascii_uppercase();\n\n // 单数 `_TOKEN` 命中 AWS_SESSION_TOKEN 等,但**不**误伤复数 `_TOKENS`\n // (CLAUDE_CODE_MAX_OUTPUT_TOKENS / MAX_THINKING_TOKENS 是正常可共享配置)。\n const SENSITIVE_SUFFIXES: &[&str] = &[\n \"_API_KEY\",\n \"_APIKEY\",\n \"_AUTH_TOKEN\",\n \"_TOKEN\",\n \"_ACCESS_KEY\",\n \"_ACCESS_KEY_ID\",\n \"_KEY_ID\",\n \"_PRIVATE_KEY\",\n ];\n const SENSITIVE_EXACT: &[&str] = &[\n \"APIKEY\",\n \"API_KEY\",\n \"TOKEN\",\n \"SECRET\",\n \"PASSWORD\",\n \"CREDENTIALS\",\n ];\n // contains:覆盖 AWS_SECRET_ACCESS_KEY / *_CLIENT_SECRET /\n // GOOGLE_APPLICATION_CREDENTIALS / AWS_BEARER_TOKEN_BEDROCK 等变体。\n const SENSITIVE_CONTAINS: &[&str] = &[\n \"SECRET\",\n \"PASSWORD\",\n \"PASSWD\",\n \"CREDENTIAL\",\n \"PRIVATE_KEY\",\n \"BEARER_TOKEN\",\n ];\n\n SENSITIVE_EXACT.contains(&upper.as_str())\n || SENSITIVE_SUFFIXES.iter().any(|s| upper.ends_with(s))\n || SENSITIVE_CONTAINS.iter().any(|c| upper.contains(c))\n }\n\n /// Extract common config for Claude (JSON format)\n fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {\n let mut config = settings.clone();\n\n // 供应商专属的**非机密**字段(模型 + 端点),不应共享。凭据/机密不在此列举,\n // 改由 `is_sensitive_config_key`(模式匹配)统一剥离,新供应商的 `*_API_KEY`\n // 等无需再手工补名单即可被覆盖。\n const ENV_PROVIDER_SPECIFIC_EXCLUDES: &[&str] = &[\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n \"ANTHROPIC_BASE_URL\",\n ];\n\n const TOP_LEVEL_EXCLUDES: &[&str] = &[\n \"apiBaseUrl\",\n // Legacy model fields\n \"primaryModel\",\n \"smallFastModel\",\n ];\n\n // Remove env fields: provider-specific (models/endpoint) + 任何凭据键。\n if let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n let sensitive: Vec<String> = env\n .keys()\n .filter(|k| Self::is_sensitive_config_key(k))\n .cloned()\n .collect();\n for key in ENV_PROVIDER_SPECIFIC_EXCLUDES {\n env.remove(*key);\n }\n for key in &sensitive {\n env.remove(key);\n }\n // If env is empty after removal, remove the env object itself\n if env.is_empty() {\n config.as_object_mut().map(|obj| obj.remove(\"env\"));\n }\n }\n\n // Remove top-level fields: legacy model fields + 任何凭据键\n // (例如非标准的顶层 apiKey / api_key / *_TOKEN)。\n if let Some(obj) = config.as_object_mut() {\n let sensitive: Vec<String> = obj\n .keys()\n .filter(|k| Self::is_sensitive_config_key(k))\n .cloned()\n .collect();\n for key in TOP_LEVEL_EXCLUDES {\n obj.remove(*key);\n }\n for key in &sensitive {\n obj.remove(key);\n }\n }\n\n // Check if result is empty\n if config.as_object().is_none_or(|obj| obj.is_empty()) {\n return Ok(\"{}\".to_string());\n }\n\n serde_json::to_string_pretty(&config)\n .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n }\n\n /// Extract common config for Codex (TOML format)\n fn extract_codex_common_config(settings: &Value) -> Result<String, AppError> {\n // Codex config is stored as { \"auth\": {...}, \"config\": \"toml string\" }\n let config_toml = settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n\n if config_toml.is_empty() {\n return Ok(String::new());\n }\n\n let mut doc = config_toml\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| AppError::Message(format!(\"TOML parse error: {e}\")))?;\n\n // Remove provider-specific fields.\n let root = doc.as_table_mut();\n root.remove(\"model\");\n root.remove(\"model_provider\");\n // Legacy/alt formats might use a top-level base_url.\n root.remove(\"base_url\");\n\n // Remove entire model_providers table (provider-specific configuration)\n root.remove(\"model_providers\");\n\n // Clean up multiple empty lines (keep at most one blank line).\n let mut cleaned = String::new();\n let mut blank_run = 0usize;\n for line in doc.to_string().lines() {\n if line.trim().is_empty() {\n blank_run += 1;\n if blank_run <= 1 {\n cleaned.push('\\n');\n }\n continue;\n }\n blank_run = 0;\n cleaned.push_str(line);\n cleaned.push('\\n');\n }\n\n Ok(cleaned.trim().to_string())\n }\n\n /// Extract common config for Gemini (JSON format)\n ///\n /// Extracts `.env` values while excluding provider-specific credentials:\n /// - GOOGLE_GEMINI_BASE_URL\n /// - GEMINI_API_KEY\n fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {\n let env = settings.get(\"env\").and_then(|v| v.as_object());\n\n let mut snippet = serde_json::Map::new();\n if let Some(env) = env {\n for (key, value) in env {\n if key == \"GOOGLE_GEMINI_BASE_URL\" || key == \"GEMINI_API_KEY\" {\n continue;\n }\n let Value::String(v) = value else {\n continue;\n };\n let trimmed = v.trim();\n if !trimmed.is_empty() {\n snippet.insert(key.to_string(), Value::String(trimmed.to_string()));\n }\n }\n }\n\n if snippet.is_empty() {\n return Ok(\"{}\".to_string());\n }\n\n serde_json::to_string_pretty(&Value::Object(snippet))\n .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n }\n\n /// Extract common config for OpenCode (JSON format)\n fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {\n // OpenCode uses a different config structure with npm, options, models\n // For common config, we exclude provider-specific fields like apiKey\n let mut config = settings.clone();\n\n // Remove provider-specific fields\n if let Some(obj) = config.as_object_mut() {\n if let Some(options) = obj.get_mut(\"options\").and_then(|v| v.as_object_mut()) {\n options.remove(\"apiKey\");\n options.remove(\"baseURL\");\n }\n // Keep npm and models as they might be common\n }\n\n if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {\n return Ok(\"{}\".to_string());\n }\n\n serde_json::to_string_pretty(&config)\n .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n }\n\n /// Extract common config for OpenClaw (JSON format)\n fn extract_openclaw_common_config(settings: &Value) -> Result<String, AppError> {\n // OpenClaw uses a different config structure with baseUrl, apiKey, api, models\n // For common config, we exclude provider-specific fields like apiKey\n let mut config = settings.clone();\n\n // Remove provider-specific fields\n if let Some(obj) = config.as_object_mut() {\n obj.remove(\"apiKey\");\n obj.remove(\"baseUrl\");\n // Keep api and models as they might be common\n }\n\n if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {\n return Ok(\"{}\".to_string());\n }\n\n serde_json::to_string_pretty(&config)\n .map_err(|e| AppError::Message(format!(\"Serialization failed: {e}\")))\n }\n\n /// Import default configuration from live files (re-export)\n ///\n /// Returns `Ok(true)` if imported, `Ok(false)` if skipped.\n pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {\n import_default_config(state, app_type)\n }\n\n pub fn should_import_default_config_on_startup(\n state: &AppState,\n app_type: &AppType,\n ) -> Result<bool, AppError> {\n should_import_default_config_on_startup(state, app_type)\n }\n\n /// Read current live settings (re-export)\n pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {\n read_live_settings(app_type)\n }\n\n /// Get custom endpoints list (re-export)\n pub fn get_custom_endpoints(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n ) -> Result<Vec<CustomEndpoint>, AppError> {\n endpoints::get_custom_endpoints(state, app_type, provider_id)\n }\n\n /// Add custom endpoint (re-export)\n pub fn add_custom_endpoint(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n url: String,\n ) -> Result<(), AppError> {\n endpoints::add_custom_endpoint(state, app_type, provider_id, url)\n }\n\n /// Remove custom endpoint (re-export)\n pub fn remove_custom_endpoint(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n url: String,\n ) -> Result<(), AppError> {\n endpoints::remove_custom_endpoint(state, app_type, provider_id, url)\n }\n\n /// Update endpoint last used timestamp (re-export)\n pub fn update_endpoint_last_used(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n url: String,\n ) -> Result<(), AppError> {\n endpoints::update_endpoint_last_used(state, app_type, provider_id, url)\n }\n\n /// Update provider sort order\n pub fn update_sort_order(\n state: &AppState,\n app_type: AppType,\n updates: Vec<ProviderSortUpdate>,\n ) -> Result<bool, AppError> {\n let mut providers = state.db.get_all_providers(app_type.as_str())?;\n\n for update in updates {\n if let Some(provider) = providers.get_mut(&update.id) {\n provider.sort_index = Some(update.sort_index);\n state.db.save_provider(app_type.as_str(), provider)?;\n }\n }\n\n Ok(true)\n }\n\n /// Query provider usage (re-export)\n pub async fn query_usage(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n ) -> Result<UsageResult, AppError> {\n usage::query_usage(state, app_type, provider_id).await\n }\n\n /// Test usage script (re-export)\n #[allow(clippy::too_many_arguments)]\n pub async fn test_usage_script(\n state: &AppState,\n app_type: AppType,\n provider_id: &str,\n script_code: &str,\n timeout: u64,\n api_key: Option<&str>,\n base_url: Option<&str>,\n access_token: Option<&str>,\n user_id: Option<&str>,\n template_type: Option<&str>,\n ) -> Result<UsageResult, AppError> {\n usage::test_usage_script(\n state,\n app_type,\n provider_id,\n script_code,\n timeout,\n api_key,\n base_url,\n access_token,\n user_id,\n template_type,\n )\n .await\n }\n\n pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {\n write_gemini_live(provider)\n }\n\n fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {\n match app_type {\n AppType::Claude => {\n if !provider.settings_config.is_object() {\n return Err(AppError::localized(\n \"provider.claude.settings.not_object\",\n \"Claude 配置必须是 JSON 对象\",\n \"Claude configuration must be a JSON object\",\n ));\n }\n }\n AppType::ClaudeDesktop => {\n crate::claude_desktop_config::validate_provider(provider)?;\n }\n AppType::Codex => {\n let settings = provider.settings_config.as_object().ok_or_else(|| {\n AppError::localized(\n \"provider.codex.settings.not_object\",\n \"Codex 配置必须是 JSON 对象\",\n \"Codex configuration must be a JSON object\",\n )\n })?;\n\n let auth = settings.get(\"auth\").ok_or_else(|| {\n AppError::localized(\n \"provider.codex.auth.missing\",\n format!(\"供应商 {} 缺少 auth 配置\", provider.id),\n format!(\"Provider {} is missing auth configuration\", provider.id),\n )\n })?;\n if !auth.is_object() {\n return Err(AppError::localized(\n \"provider.codex.auth.not_object\",\n format!(\"供应商 {} 的 auth 配置必须是 JSON 对象\", provider.id),\n format!(\n \"Provider {} auth configuration must be a JSON object\",\n provider.id\n ),\n ));\n }\n\n if let Some(config_value) = settings.get(\"config\") {\n if !(config_value.is_string() || config_value.is_null()) {\n return Err(AppError::localized(\n \"provider.codex.config.invalid_type\",\n \"Codex config 字段必须是字符串\",\n \"Codex config field must be a string\",\n ));\n }\n if let Some(cfg_text) = config_value.as_str() {\n crate::codex_config::validate_config_toml(cfg_text)?;\n }\n }\n }\n AppType::Gemini => {\n use crate::gemini_config::validate_gemini_settings;\n validate_gemini_settings(&provider.settings_config)?\n }\n AppType::OpenCode => {\n // OpenCode uses a different config structure: { npm, options, models }\n // Basic validation - must be an object\n if !provider.settings_config.is_object() {\n return Err(AppError::localized(\n \"provider.opencode.settings.not_object\",\n \"OpenCode 配置必须是 JSON 对象\",\n \"OpenCode configuration must be a JSON object\",\n ));\n }\n }\n AppType::OpenClaw => {\n // OpenClaw uses config structure: { baseUrl, apiKey, api, models }\n // Basic validation - must be an object\n if !provider.settings_config.is_object() {\n return Err(AppError::localized(\n \"provider.openclaw.settings.not_object\",\n \"OpenClaw 配置必须是 JSON 对象\",\n \"OpenClaw configuration must be a JSON object\",\n ));\n }\n }\n AppType::Hermes => {\n // Hermes: accept any JSON object for now\n if !provider.settings_config.is_object() {\n return Err(AppError::localized(\n \"provider.hermes.settings.not_object\",\n \"Hermes 配置必须是 JSON 对象\",\n \"Hermes configuration must be a JSON object\",\n ));\n }\n }\n }\n\n // Validate and clean UsageScript configuration (common for all app types)\n if let Some(meta) = &provider.meta {\n if let Some(multiplier) = meta.cost_multiplier.as_deref() {\n validate_cost_multiplier(multiplier)?;\n }\n if let Some(source) = meta.pricing_model_source.as_deref() {\n validate_pricing_source(source)?;\n }\n if let Some(usage_script) = &meta.usage_script {\n validate_usage_script(usage_script)?;\n }\n }\n\n Ok(())\n }\n\n #[allow(dead_code)]\n fn extract_credentials(\n provider: &Provider,\n app_type: &AppType,\n ) -> Result<(String, String), AppError> {\n match app_type {\n AppType::Claude => {\n let env = provider\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.claude.env.missing\",\n \"配置格式错误: 缺少 env\",\n \"Invalid configuration: missing env section\",\n )\n })?;\n\n let api_key = env\n .get(\"ANTHROPIC_AUTH_TOKEN\")\n .or_else(|| env.get(\"ANTHROPIC_API_KEY\"))\n .and_then(|v| v.as_str())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.claude.api_key.missing\",\n \"缺少 API Key\",\n \"API key is missing\",\n )\n })?\n .to_string();\n\n let base_url = env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.claude.base_url.missing\",\n \"缺少 ANTHROPIC_BASE_URL 配置\",\n \"Missing ANTHROPIC_BASE_URL configuration\",\n )\n })?\n .to_string();\n\n Ok((api_key, base_url))\n }\n AppType::ClaudeDesktop => {\n let credentials =\n crate::claude_desktop_config::direct_gateway_credentials(provider)?;\n Ok((credentials.api_key, credentials.base_url))\n }\n AppType::Codex => {\n let _auth = provider\n .settings_config\n .get(\"auth\")\n .and_then(|v| v.as_object())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.codex.auth.missing\",\n \"配置格式错误: 缺少 auth\",\n \"Invalid configuration: missing auth section\",\n )\n })?;\n\n let config_toml = provider\n .settings_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n\n let api_key = crate::codex_config::extract_codex_api_key(\n provider.settings_config.get(\"auth\"),\n Some(config_toml),\n )\n .ok_or_else(|| {\n AppError::localized(\n \"provider.codex.api_key.missing\",\n \"缺少 API Key\",\n \"API key is missing\",\n )\n })?;\n\n let base_url = if config_toml.contains(\"base_url\") {\n let re = Regex::new(r#\"base_url\\s*=\\s*[\"']([^\"']+)[\"']\"#).map_err(|e| {\n AppError::localized(\n \"provider.regex_init_failed\",\n format!(\"正则初始化失败: {e}\"),\n format!(\"Failed to initialize regex: {e}\"),\n )\n })?;\n re.captures(config_toml)\n .and_then(|caps| caps.get(1))\n .map(|m| m.as_str().to_string())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.codex.base_url.invalid\",\n \"config.toml 中 base_url 格式错误\",\n \"base_url in config.toml has invalid format\",\n )\n })?\n } else {\n return Err(AppError::localized(\n \"provider.codex.base_url.missing\",\n \"config.toml 中缺少 base_url 配置\",\n \"base_url is missing from config.toml\",\n ));\n };\n\n Ok((api_key, base_url))\n }\n AppType::Gemini => {\n use crate::gemini_config::json_to_env;\n\n let env_map = json_to_env(&provider.settings_config)?;\n\n let api_key = env_map.get(\"GEMINI_API_KEY\").cloned().ok_or_else(|| {\n AppError::localized(\n \"gemini.missing_api_key\",\n \"缺少 GEMINI_API_KEY\",\n \"Missing GEMINI_API_KEY\",\n )\n })?;\n\n let base_url = env_map\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .cloned()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com\".to_string());\n\n Ok((api_key, base_url))\n }\n AppType::OpenCode => {\n // OpenCode uses options.apiKey and options.baseURL\n let options = provider\n .settings_config\n .get(\"options\")\n .and_then(|v| v.as_object())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.opencode.options.missing\",\n \"配置格式错误: 缺少 options\",\n \"Invalid configuration: missing options section\",\n )\n })?;\n\n let api_key = options\n .get(\"apiKey\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.opencode.api_key.missing\",\n \"缺少 API Key\",\n \"API key is missing\",\n )\n })?\n .to_string();\n\n let base_url = options\n .get(\"baseURL\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\")\n .to_string();\n\n Ok((api_key, base_url))\n }\n AppType::OpenClaw | AppType::Hermes => {\n // OpenClaw/Hermes use apiKey and baseUrl directly on the object\n let api_key = provider\n .settings_config\n .get(\"apiKey\")\n .and_then(|v| v.as_str())\n .ok_or_else(|| {\n AppError::localized(\n \"provider.openclaw.api_key.missing\",\n \"缺少 API Key\",\n \"API key is missing\",\n )\n })?\n .to_string();\n\n let base_url = provider\n .settings_config\n .get(\"baseUrl\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\")\n .to_string();\n\n Ok((api_key, base_url))\n }\n }\n }\n}\n\n/// Normalize Claude model keys in a JSON value\n///\n/// Reads old key (ANTHROPIC_SMALL_FAST_MODEL), writes new keys (DEFAULT_*), and deletes old key.\npub(crate) fn normalize_claude_models_in_value(settings: &mut Value) -> bool {\n let mut changed = false;\n let env = match settings.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n Some(obj) => obj,\n None => return changed,\n };\n\n let model = env\n .get(\"ANTHROPIC_MODEL\")\n .and_then(|v| v.as_str())\n .map(|s| s.to_string());\n let small_fast = env\n .get(\"ANTHROPIC_SMALL_FAST_MODEL\")\n .and_then(|v| v.as_str())\n .map(|s| s.to_string());\n\n let current_haiku = env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str())\n .map(|s| s.to_string());\n let current_sonnet = env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str())\n .map(|s| s.to_string());\n let current_opus = env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str())\n .map(|s| s.to_string());\n\n let target_haiku = current_haiku\n .or_else(|| small_fast.clone())\n .or_else(|| model.clone());\n let target_sonnet = current_sonnet\n .or_else(|| model.clone())\n .or_else(|| small_fast.clone());\n let target_opus = current_opus\n .or_else(|| model.clone())\n .or_else(|| small_fast.clone());\n\n if env.get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\").is_none() {\n if let Some(v) = target_haiku {\n env.insert(\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\".to_string(),\n Value::String(v),\n );\n changed = true;\n }\n }\n if env.get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\").is_none() {\n if let Some(v) = target_sonnet {\n env.insert(\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\".to_string(),\n Value::String(v),\n );\n changed = true;\n }\n }\n if env.get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\").is_none() {\n if let Some(v) = target_opus {\n env.insert(\"ANTHROPIC_DEFAULT_OPUS_MODEL\".to_string(), Value::String(v));\n changed = true;\n }\n }\n\n if env.remove(\"ANTHROPIC_SMALL_FAST_MODEL\").is_some() {\n changed = true;\n }\n\n changed\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ProviderSortUpdate {\n pub id: String,\n #[serde(rename = \"sortIndex\")]\n pub sort_index: usize,\n}\n\n// ============================================================================\n// 统一供应商(Universal Provider)服务方法\n// ============================================================================\n\nuse crate::provider::UniversalProvider;\nuse std::collections::HashMap;\n\nimpl ProviderService {\n /// 获取所有统一供应商\n pub fn list_universal(\n state: &AppState,\n ) -> Result<HashMap<String, UniversalProvider>, AppError> {\n state.db.get_all_universal_providers()\n }\n\n /// 获取单个统一供应商\n pub fn get_universal(\n state: &AppState,\n id: &str,\n ) -> Result<Option<UniversalProvider>, AppError> {\n state.db.get_universal_provider(id)\n }\n\n /// 添加或更新统一供应商(不自动同步,需手动调用 sync_universal_to_apps)\n pub fn upsert_universal(\n state: &AppState,\n provider: UniversalProvider,\n ) -> Result<bool, AppError> {\n // 保存统一供应商\n state.db.save_universal_provider(&provider)?;\n\n Ok(true)\n }\n\n /// 删除统一供应商\n pub fn delete_universal(state: &AppState, id: &str) -> Result<bool, AppError> {\n // 获取统一供应商(用于删除生成的子供应商)\n let provider = state.db.get_universal_provider(id)?;\n\n // 删除统一供应商\n state.db.delete_universal_provider(id)?;\n\n // 删除生成的子供应商\n if let Some(p) = provider {\n if p.apps.claude {\n let claude_id = format!(\"universal-claude-{id}\");\n let _ = state.db.delete_provider(\"claude\", &claude_id);\n }\n if p.apps.codex {\n let codex_id = format!(\"universal-codex-{id}\");\n let _ = state.db.delete_provider(\"codex\", &codex_id);\n }\n if p.apps.gemini {\n let gemini_id = format!(\"universal-gemini-{id}\");\n let _ = state.db.delete_provider(\"gemini\", &gemini_id);\n }\n }\n\n Ok(true)\n }\n\n /// 同步统一供应商到各应用\n pub fn sync_universal_to_apps(state: &AppState, id: &str) -> Result<bool, AppError> {\n let provider = state\n .db\n .get_universal_provider(id)?\n .ok_or_else(|| AppError::Message(format!(\"统一供应商 {id} 不存在\")))?;\n\n // 同步到 Claude\n if let Some(mut claude_provider) = provider.to_claude_provider() {\n // 合并已有配置\n if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, \"claude\")? {\n let mut merged = existing.settings_config.clone();\n Self::merge_json(&mut merged, &claude_provider.settings_config);\n claude_provider.settings_config = merged;\n }\n state.db.save_provider(\"claude\", &claude_provider)?;\n } else {\n // 如果禁用了 Claude,删除对应的子供应商\n let claude_id = format!(\"universal-claude-{id}\");\n let _ = state.db.delete_provider(\"claude\", &claude_id);\n }\n\n // 同步到 Codex\n if let Some(mut codex_provider) = provider.to_codex_provider() {\n // 合并已有配置\n if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, \"codex\")? {\n let mut merged = existing.settings_config.clone();\n Self::merge_json(&mut merged, &codex_provider.settings_config);\n codex_provider.settings_config = merged;\n }\n state.db.save_provider(\"codex\", &codex_provider)?;\n } else {\n let codex_id = format!(\"universal-codex-{id}\");\n let _ = state.db.delete_provider(\"codex\", &codex_id);\n }\n\n // 同步到 Gemini\n if let Some(mut gemini_provider) = provider.to_gemini_provider() {\n // 合并已有配置\n if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, \"gemini\")? {\n let mut merged = existing.settings_config.clone();\n Self::merge_json(&mut merged, &gemini_provider.settings_config);\n gemini_provider.settings_config = merged;\n }\n state.db.save_provider(\"gemini\", &gemini_provider)?;\n } else {\n let gemini_id = format!(\"universal-gemini-{id}\");\n let _ = state.db.delete_provider(\"gemini\", &gemini_id);\n }\n\n Ok(true)\n }\n\n /// 递归合并 JSON:base 为底,patch 覆盖同名字段\n fn merge_json(base: &mut serde_json::Value, patch: &serde_json::Value) {\n use serde_json::Value;\n\n match (base, patch) {\n (Value::Object(base_map), Value::Object(patch_map)) => {\n for (k, v_patch) in patch_map {\n match base_map.get_mut(k) {\n Some(v_base) => Self::merge_json(v_base, v_patch),\n None => {\n base_map.insert(k.clone(), v_patch.clone());\n }\n }\n }\n }\n // 其它类型:直接覆盖\n (base_val, patch_val) => {\n *base_val = patch_val.clone();\n }\n }\n }\n}\n"}}}}