Files

1 line
7.3 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": "aaroen/cc-switch-cli", "files": {"src/config/opencodeProviderPresets.ts": {"bab9119da3c9dbecaeca84f2a022c9b7ad8e52fe": {"t": 1785595989.2595184, "n": 46635, "h": "ddbf64040f345902", "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 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-4-6\",\n name: \"Claude Sonnet 4.6\",\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-4.6\": { name: \"Claude Sonnet 4.6\" },\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: \"火山Agentplan\",\n websiteUrl:\n \"https://www.volcengine.com/activity/agentplan?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/agentplan?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-0-code-preview-latest\": {\n name: \"Doubao Seed Code Preview\",\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: \"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 k2.6\",\n websiteUrl: \"https://platform.moonshot.cn/console\",\n apiKeyUrl: \"https://platform.moonshot.cn/console/api-keys\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"Kimi k2.6\",\n options: {\n baseURL: \"https://api.moonshot.cn/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"kimi-k2.6\": { name: \"Kimi K2.6\" },\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 websiteUrl: \"https://www.kimi.com/code/docs/\",\n apiKeyUrl: \"https://platform.moonshot.cn/console/api-keys\",\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/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"LongCat-Flash-Chat\": { name: \"LongCat Flash Chat\" },\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/v1\",\n defaultValue: \"https://api.longcat.chat/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 isPartner: true,\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 isPartner: true,\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: \"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-4-6\": { name: \"Claude Sonnet 4.6\" },\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-4.6\": { name: \"Claude Sonnet 4.6\" },\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-4-6\": { name: \"Claude Sonnet 4.6\" },\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-4.6\": { name: \"Claude Sonnet 4.6\" },\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-4.6\": { name: \"Claude Sonnet 4.6\" },\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-4-6\": { name: \"claude-sonnet-4-6\" },\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-4-6\": { name: \"Claude Sonnet 4.6\" },\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-4-6\": { name: \"Claude Sonnet 4.6\" },\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\",\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.us\",\n apiKeyUrl: \"https://sudocode.us\",\n settingsConfig: {\n npm: \"@ai-sdk/openai\",\n name: \"SudoCode\",\n options: {\n baseURL: \"https://sudocode.us/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: \"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-4-6\": { name: \"Claude Sonnet 4.6\" },\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-4-6\": { name: \"Claude Sonnet 4.6\" },\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-4.6\": { name: \"Claude Sonnet 4.6\" },\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-4-6\": { name: \"Claude Sonnet 4.6\" },\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-4-6\": { name: \"Claude Sonnet 4.6\" },\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-4-6\": { name: \"Claude Sonnet 4.6\" },\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://www.sssaicode.com\",\n apiKeyUrl: \"https://www.sssaicode.com/register?ref=DCP0SM\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"SSSAiCode\",\n options: {\n baseURL: \"https://node-hk.sssaicode.com/api/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-sonnet-4-6\": { name: \"Claude Sonnet 4.6\" },\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-4-6\": { name: \"Claude Sonnet 4.6\" },\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: \"CTok.ai\",\n websiteUrl: \"https://ctok.ai\",\n apiKeyUrl: \"https://ctok.ai\",\n settingsConfig: {\n npm: \"@ai-sdk/anthropic\",\n name: \"CTok\",\n options: {\n baseURL: \"https://api.ctok.ai/v1\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {\n \"claude-opus-4-8\": { name: \"Claude Opus 4.8\" },\n \"claude-sonnet-4-6\": { name: \"Claude Sonnet 4.6\" },\n },\n },\n category: \"third_party\",\n isPartner: true,\n partnerPromotionKey: \"ctok\",\n icon: \"ctok\",\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: \"LemonData\",\n websiteUrl: \"https://lemondata.cc\",\n apiKeyUrl: \"https://lemondata.cc/r/FFX1ZDUP\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n name: \"LemonData\",\n options: {\n baseURL: \"https://api.lemondata.cc/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: \"lemondata\",\n icon: \"lemondata\",\n templateValues: {\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\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-4-6\": {\n name: \"Claude Sonnet 4.6\",\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: \"OpenAI Compatible\",\n websiteUrl: \"\",\n settingsConfig: {\n npm: \"@ai-sdk/openai-compatible\",\n options: {\n baseURL: \"\",\n apiKey: \"\",\n setCacheKey: true,\n },\n models: {},\n },\n category: \"custom\",\n isCustomTemplate: true,\n icon: \"generic\",\n iconColor: \"#6B7280\",\n templateValues: {\n baseURL: {\n label: \"Base URL\",\n placeholder: \"https://api.example.com/v1\",\n editorValue: \"\",\n },\n apiKey: {\n label: \"API Key\",\n placeholder: \"\",\n editorValue: \"\",\n },\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"}}, "CHANGELOG.md": {"bab9119da3c9dbecaeca84f2a022c9b7ad8e52fe": {"t": 1785596033.803823, "n": 157555, "h": "7d6494f0de4f861a", "c": "# Changelog\n\nAll notable changes to CC Switch will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [3.16.1] - 2026-06-01\n\nDevelopment since v3.16.0 focuses on hardening Codex provider switching and Local Routing takeover: preserving official OAuth auth and model catalogs across normal switches, hot-switches, backup restore, and edit flows; restoring Codex Chat tool/plugin compatibility over Chat Completions upstreams; improving Codex proxy diagnostics and CLI discovery; and documenting DeepSeek routing.\n\n**Stats**: 23 commits | 62 files changed | +5,603 insertions | -1,113 deletions\n\n### Added\n\n- **Codex Official Auth Preservation Setting**: Added an opt-in setting that keeps the official ChatGPT / Codex OAuth login in `auth.json` when switching third-party Codex providers, while moving third-party provider tokens into `config.toml` when enabled.\n- **Codex DeepSeek Routing Guides**: Added localized DeepSeek routing guides for Codex in English, Chinese, and Japanese, with screenshots covering provider routing requirements, Codex provider setup, and Local Routing takeover.\n\n### Changed\n\n- **Codex Auth Preservation Is Opt-In**: The new official-auth preservation setting now defaults to off, so third-party Codex switches keep the legacy behavior of writing the active provider auth unless users explicitly enable preservation.\n- **Codex Provider Switch Restart Hint**: Successful Codex provider switches now tell users to restart the Codex client so catalog and config changes take effect.\n- **Codex Proxy Takeover Switching Is Serialized**: Provider switches and takeover toggles now share a per-app lock and use backup / live placeholder ownership signals instead of lagging `enabled` or server-running flags, preventing normal live writes from racing a just-activated or temporarily stopped takeover.\n- **Codex Takeover Hot-Switch Display Refresh**: Hot-switching a Codex provider while Local Routing owns Live now refreshes the proxy-safe live provider id, model, and display name while keeping endpoints pointed at the local proxy.\n- **Sponsor Ordering**: Swapped the Shengsuanyun and AICodeMirror sponsor blocks across README locales.\n- **Docs Organization**: Moved the Chinese proxy guide under `docs/guides/` and removed the obsolete working-directory plan document.\n\n### Fixed\n\n- **Codex Provider Edit Dialog Under Takeover**: The Codex provider edit form now shows an explicit notice and storage-aware auth / config hints clarifying that it displays the stored provider config (not the proxy-managed live `auth.json` / `config.toml`), so the official OAuth token is no longer mistaken as lost while takeover is active. The dialog also treats takeover as active regardless of whether the proxy server is currently running.\n- **Codex OAuth Auth During Proxy Takeover**: Fixed multiple preserve-mode takeover paths that could clear or overwrite the official ChatGPT / Codex OAuth `auth.json`. Takeover detection now recognizes `PROXY_MANAGED` in `config.toml`, cleanup only removes placeholder bearer tokens, config-only takeover writes are used consistently, and mis-categorized third-party providers no longer trigger the official-provider auth overwrite path. Provider sync and switching now treat the restore backup and live placeholders as the takeover-ownership signal (instead of the lagging `enabled` / proxy-running flags) and serialize switch/takeover per app, so a just-activated or proxy-stopped takeover can no longer be overwritten by a normal live write.\n- **Codex Model Catalog Data Loss**: Fixed cases where Codex `modelCatalog` could be wiped by live-config backfill, active-provider edit dialogs, provider switches, or proxy takeover-off restore. Snapshot backups now keep existing `model_catalog_json` pointers, provider-rebuilt backups regenerate the catalog projection from the database source of truth, active edit dialogs prefer the DB catalog over lossy Live reconstruction, and provider switches always refresh the generated catalog JSON.\n- **Codex Chat Tools Over Chat Completions Routing**: Restored Codex `tool_search`, loaded namespace tools, custom tools, and tool outputs when third-party Codex providers are routed through Chat Completions. Non-streaming and streaming Chat responses now map back to the right Responses item types, including native `response.custom_tool_call_input.*` events for custom-tool streaming.\n- **Codex Proxy Error Diagnostics**: Codex forwarding failures now return richer JSON errors with provider, model, endpoint, upstream status, stable `cc_switch_*` codes, and HTTP statuses aligned with the canonical `ProxyError` response mapping.\n- **Codex Native Balance / Coding-Plan Queries**: Fixed native usage and plan lookups so each app resolves the correct provider credentials instead of leaking assumptions from another app surface.\n- **Codex CLI Discovery and Catalog Projection**: Fixed third-party Codex catalog projection that could fail when the Codex CLI was not reachable through one narrow PATH lookup, by adding multi-platform CLI discovery plus a bundled GPT-5.5 model-catalog template fallback.\n- **Claude Desktop Official Provider Creation**: Fixed adding the Claude Desktop Official provider when the official category/config path was selected.\n- **Anthropic Tool Thinking History for Kimi / Moonshot**: Added Kimi and Moonshot to the Anthropic-compatible tool-thinking history normalizer so later turns can replay reasoning and tool-call context correctly.\n- **Windows Tool Version Probing**: Fixed Windows version checks that could misquote `.cmd` / `.bat` commands and decode localized command output as mojibake, causing working tools to appear as \"installed but not runnable\".\n\n## [3.16.0] - 2026-05-29\n\nDevelopment since v3.15.0 focuses on making third-party Codex providers work like first-class citizens through Chat Completions routing, stabilizing Codex provider identity and history, adding an in-app managed CLI tool lifecycle, expanding the partner preset catalog, refreshing the default model / pricing matrix around GPT-5.5 and Claude Opus 4.8, and improving usage observability, localization, docs, and proxy robustness.\n\n**Stats**: 101 commits | 221 files changed | +27,063 insertions | -3,052 deletions\n\n### Highlights\n\n- **Codex Chat Completions routing**: Codex providers can now be served by OpenAI-compatible Chat Completions upstreams. CC Switch converts Codex Responses requests into Chat Completions, rebuilds JSON and SSE responses back into Responses shape, preserves reasoning / `<think>` / tool-call state, normalizes error envelopes, and probes Chat-format providers correctly in Stream Check.\n- **Codex third-party provider state is unified and safer**: third-party Codex providers now share the stable `custom` model-provider bucket, with a one-shot migration for historical JSONL sessions and `state_5.sqlite` threads, plus fixes that preserve OAuth login state, user-selected catalog models, and user-authored provider ids during live reads / switches.\n- **Managed CLI tool management**: the About page is now a tool management panel for Claude, Codex, Gemini, OpenCode, OpenClaw, and Hermes, with install / update actions, update-all, conflict diagnostics, source-aware anchored upgrades, WSL handling, and visible \"installed but not runnable\" states.\n- **Provider ecosystem and model matrix refresh**: added APIKEY.FUN, APINebula, AtlasCloud, SudoCode, Xiaomi MiMo Token Plan, and Claude Desktop Official presets; refreshed partner links and default models / pricing across apps; upgraded the default Claude Opus model line to 4.8 and GPT defaults to 5.5 where applicable.\n- **Usage and docs polish**: Usage Dashboard updates now react immediately when logs are written, custom usage-script summaries and subagent session-log accounting were fixed, Traditional Chinese UI localization landed, and a German README plus expanded Claude Desktop / Codex Chat / tool-management manuals were added.\n- **Proxy and conversion hardening**: fixed Codex Chat reasoning / cache / usage edge cases, DeepSeek Anthropic tool-thinking history, Claude-compatible empty `tool_calls` streams, managed-account takeover auth, MiMo reasoning output, Gemini Native tool-call replay, and several panic-prone proxy paths.\n\n### Added\n\n- **Codex Chat Completions Routing**: Codex providers can now be served by upstreams that only speak the OpenAI Chat Completions API. CC Switch's local proxy converts Codex's outgoing Responses requests into Chat Completions and rebuilds the Chat response (both JSON and SSE) back into Responses shape, preserving `reasoning_content`, inline `<think>` blocks, streamed reasoning summaries, tool calls, and `previous_response_id` follow-ups. A bounded Codex Chat history cache restores tool calls before their tool outputs.\n- **22 Codex Third-Party Provider Presets with Chat Routing**: Enabled Chat Completions routing with explicit model catalogs for major Chinese/Asian providers — DeepSeek, Zhipu GLM (+ en), Kimi, MiniMax (+ en), StepFun (+ en), Baidu Qianfan Coding Plan, Bailian, ModelScope, Longcat, BaiLing, Xiaomi MiMo (+ Token Plan), Volcengine Agentplan, BytePlus, DouBao Seed, SiliconFlow (+ en), Novita AI, and Nvidia. Each preset declares its context window so the UI can size the model-mapping rows.\n- **Codex Model Mapping Table**: Codex provider forms now expose a model catalog (model + display name + context window per row) that is the single source of truth for the upstream model list, projected to `~/.codex/cc-switch-model-catalog.json`.\n- **Codex Chat Providers in Stream Check**: Stream Check now probes Chat-format Codex providers against `/chat/completions` with a Chat-shaped body instead of `/v1/responses`, and aligns its URL fallback order with the production `CodexAdapter` (origin-only base URLs hit `/v1/<endpoint>` first) so a non-404 error on the bare path no longer flags a working provider as down.\n- **Codex Chat Reasoning Auto-Detection**: When a Codex provider is served through Chat Completions routing, CC Switch now auto-detects the upstream's reasoning interface from its name, base URL, and model — injecting the correct thinking parameter (`thinking:{type}`, `enable_thinking`, `reasoning_split`, top-level `reasoning_effort`, or OpenRouter's native `reasoning:{effort}` object) with no manual setup. Aggregator/hosting platforms (OpenRouter, SiliconFlow) are matched platform-first, since the same model can expose different reasoning controls on different platforms. Providers that only expose a thinking on/off switch (Kimi, GLM, Qwen, MiniMax, MiMo, SiliconFlow) drop the effort *level* instead of forwarding an unsupported field — so changing Codex's reasoning effort has no effect for them — while providers with real effort tiers (DeepSeek, OpenRouter, and StepFun's `step-3.5-flash-2603` only) pass the level through. OpenRouter specifically uses the native `reasoning:{effort}` object, clamps `max` to `xhigh` (its enum has no `max`), and forwards an explicit `effort:\"none\"` so reasoning can be turned off.\n- **Codex Goal Mode and Remote Compaction Controls**: Codex config editing now exposes a Goal Mode toggle and a Remote Compaction toggle for third-party providers; new Codex templates default to `disable_response_storage = true` while still allowing explicit goal support.\n- **Xiaomi MiMo Token Plan Presets**: Added Xiaomi MiMo Token Plan presets with specs aligned to the official documentation (#2803).\n- **Claude Desktop Official Preset**: Added a Claude Desktop Official preset that restores the native Claude Desktop login, plus a localized Claude Desktop user guide (en / zh / ja).\n- **Managed CLI Tool Lifecycle**: Added silent install / update commands for managed CLI tools, latest-version checks, per-tool and batch actions, update-all, and diagnostics for multiple installations across PATH, Homebrew, npm, pnpm, bun, volta, fnm, nvm, scoop, WinGet, Windows native paths, and WSL.\n- **Source-Aware Tool Diagnostics**: The Settings / About surface can now diagnose conflicting tool installations, show the concrete install source and version for each path, and generate backend-planned upgrade commands anchored to the actual installation source.\n- **Real-Time Usage Refresh**: The backend now emits `usage-log-recorded` when proxy logs, session-log syncs, or rollups write usage data; Usage Dashboard listens for that event and invalidates its queries immediately instead of waiting for the next polling interval (#3027).\n- **Traditional Chinese Localization**: Added `zh-TW` UI localization and a settings language option (#3093).\n- **German README**: Added `README_DE.md` and linked it from the existing README language switchers (#2994).\n- **New Partner Presets**: Added APIKEY.FUN, APINebula, AtlasCloud, and SudoCode partner presets across the supported app surfaces, with partner copy, icons, and README entries.\n\n### Changed\n\n- **Codex Third-Party Providers Unified into a \"custom\" History Bucket**: Codex filters resume history by `model_provider`, so switching between provider-specific ids made past sessions appear to vanish. All third-party providers now normalize to a single stable `custom` bucket (reserved built-in ids like `openai` / `ollama` are preserved), with a one-shot device migration that rewrites historical JSONL sessions and the `state_5.sqlite` threads table and backs up originals under `~/.cc-switch/backups/codex-history-provider-migration-v1/`.\n- **Codex Provider Form Simplified**: Removed the API Format selector from the Codex form (`wire_api` is always `responses`, so the selector misleadingly implied a protocol change); the model mapping table is now the only source of truth with no hidden default entries, and the form notes that a Codex restart is required after catalog changes since `model_catalog_json` is loaded at startup. Only the \"Needs Local Routing\" toggle remains.\n- **Codex Local Routing Toggle Hints Rewritten**: Reframed the OFF / ON hints as action guidance (when to enable) rather than scenario descriptions, synced across zh / en / ja.\n- **Codex Live Config Preservation**: Live Codex config reads no longer force-rewrite a user's `model_provider` field, and provider-scoped `experimental_bearer_token` handling now preserves OAuth login state when switching between third-party providers.\n- **Tool Install / Upgrade Strategy**: Managed tool installation now prefers official native installers where available, falls back to package managers when appropriate, runs self-update first for compatible tools, anchors upgrades to the detected install source, and locks duplicate batch actions while work is in flight.\n- **About Page Becomes Tool Management**: The About settings page now presents installed / latest versions, install and update actions, conflict diagnostics, WSL shell preferences, and clearer status for broken or unrunnable tools.\n- **Default Models and Pricing Refreshed**: Upgraded the default Claude Opus model to 4.8, moved GPT-based presets and templates to GPT-5.5 where applicable, refreshed pricing seeds, aligned Claude Desktop model mapping with Claude Code's three-role tiers, and renamed the OpenCode Go preset to drop a stale model suffix.\n- **Partner Links Refreshed**: Updated ShengSuanYun referral links, Atlas Cloud UTM links, and partner copy across README locales and provider metadata.\n- **Homebrew Official Cask Installation**: Installation simplified to `brew install --cask cc-switch` now that CC Switch is in the official Homebrew repository; the personal-tap requirement was removed from all READMEs.\n- **Shared Frontend Utilities**: Replaced JSON stringify / parse deep-copy patterns with a shared `deepClone` helper and extracted a shared `useTauriEvent` hook (#3140).\n\n### Fixed\n\n- **Codex Chat Error Responses Converted to Responses Envelope**: The Codex Chat-to-Responses bridge previously passed upstream error bodies through untouched, leaving Codex clients unable to recognize MiniMax `base_resp`, raw OpenAI Chat errors, or plain-text / HTML error pages. Errors are now regularized into the standard `{error: {message, type, code, param}}` envelope with the original HTTP status preserved; non-JSON bodies are wrapped and truncated to 1KB at a UTF-8 char boundary. Also fixed a pre-existing append-vs-insert bug that emitted a duplicate `Content-Type` header on rewritten JSON bodies.\n- **Codex Mid-Stream System Messages Collapsed**: MiniMax's OpenAI-compatible endpoint strict-rejects any non-leading `system` message (error 2013). All `system` fragments are now collapsed into a single leading message (joined in original order), losslessly for permissive backends too.\n- **Codex Model Catalog Wiped After Restart**: Editing the active Codex provider triggered a live read that omitted `modelCatalog`, so a subsequent save silently destroyed user-configured model mappings. Live reads now reverse-parse the on-disk catalog projection to round-trip the same shape the save path writes.\n- **Codex Model Catalog Infinite Render Loop**: Broke a bidirectional sync cycle between the catalog table and its parent state that caused severe UI jittering when adding or editing entries.\n- **Codex Chat Preserves User-Selected Catalog Model**: A model the client selects from the catalog (e.g. via `/model`) is no longer overwritten by `config.toml`'s default model.\n- **Codex Chat Reasoning and Cache Stability**: Restored a unique call-id fallback when Codex omits or rewrites `previous_response_id`, stopped deriving cache identity from `previous_response_id`, and canonicalized parseable JSON string payloads in tool conversions for stable prefix-cache reuse.\n- **Codex Chat Streaming Usage Recovered**: The Responses-to-Chat conversion now injects `stream_options.include_usage` (merging into any client-provided `stream_options`) when a request is streaming, so OpenAI-compatible upstreams like Kimi and MiniMax emit the trailing usage chunk again. Previously their streamed token / cost / cache stats were recorded as zero on the Codex Chat path.\n- **Codex Chat Tool-Call Reasoning Backfill**: Thinking models like Kimi/Moonshot and DeepSeek reject an assistant message that carries `tool_calls` without a non-empty `reasoning_content`. When cross-turn history recovery misses (proxy restart, ambiguous `call_id`, or a turn with no upstream reasoning), a placeholder `reasoning_content` is now backfilled in a final pass — genuine trailing reasoning still attaches first — so the request no longer fails with `reasoning_content is missing in assistant tool call message`.\n- **Managed-Account Claude Takeover Auth**: Managed-account providers (GitHub Copilot / Codex OAuth) now drop token env keys and write only the `ANTHROPIC_API_KEY` placeholder when taking over Claude Live config, with an outbound guard that refuses to send the `PROXY_MANAGED` placeholder upstream.\n- **Claude Desktop Profile Sync During Takeover**: Claude Desktop profile data is now synced during proxy takeover, model routes align with the Claude Code three-role tiers, and the Cowork egress profile has been corrected (#3157, #3172).\n- **Managed-Account Takeover Model Fields**: Local Routing now sources takeover model fields from the target provider on managed accounts instead of carrying stale model values.\n- **DeepSeek Anthropic Tool Thinking History**: Normalized DeepSeek Anthropic-compatible tool-thinking history so later turns can replay reasoning / tool-call context without malformed messages (#3203).\n- **Claude-Compatible Empty Tool Calls in Streams**: Fixed a Claude-compatible streaming edge case where an empty `tool_calls` array reset block state and broke streamed responses (#2915).\n- **MiMo Reasoning for Claude Code Proxy**: Added MiMo `reasoning_content` support on the Claude Code proxy path (#2990).\n- **Gemini Native Tool-Call Robustness**: Fixed `functionResponse.name` resolution (422) and `thought_signature` replay (400) for synthesized tool-call IDs in long multi-turn sessions (#2814).\n- **Session Log Subagent Token Accounting**: `collect_jsonl_files()` now scans subagent JSONL logs that were previously missed, so subagent token usage is counted in session cost (session-log mode only) (#2821).\n- **Usage Dashboard / Sync Stability**: Fixed a Codex usage-sync panic on non-ASCII model names, custom usage-script summaries, and missing real-time refresh after usage rollups (#3027, #3129).\n- **ZhiPu Coding-Plan Quota Tier Ordering**: When the 5-hour bucket is at 0% utilization, ZhiPu's API omits `nextResetTime`; the old `i64::MAX` sentinel sorted those entries last, letting the weekly bucket incorrectly claim the five-hour slot. Tiers now sort so a missing `nextResetTime` maps to the five-hour bucket, so tray and usage quota display stays correct for ZhiPu coding plans.\n- **Skills Install by Key**: Installing from skills.sh search results now uses the unique key instead of the directory name, so skills that share a directory name install the correct one (#2784); also fixed a skill sync copy fallback (#2791).\n- **Usage Price Input Precision**: Reduced the price input step to 0.0001 so sub-cent costs like DeepSeek cache reads can be entered (#2793, closes #2503).\n- **Ghostty Clean Window Launch**: Ghostty now opens a single clean window instead of cloning existing tabs, and other terminals open a new window via `open -na` (#2801, closes #2798).\n- **Tool Version and Update Reliability**: Version probing no longer masks unrunnable installs, prerelease tools are handled correctly in version checks, batch updates run per tool, install / update buttons stay locked during preflight, anchored upgrade branches enforce absolute paths, and WSL installer paths use native Unix installers when needed.\n- **Codex mise Detection**: Fixed Codex mise environment detection (#2822).\n- **Codex Archived Sessions**: Codex archived sessions are now included in session discovery (#2861).\n- **Codex Chat Empty Tool Arguments**: Empty tool-call argument payloads are coerced to `{}` during Codex Chat conversion so upstreams and clients receive valid JSON.\n- **Claude Provider Deeplink Imports**: Importing Claude providers through deeplinks now preserves custom environment fields (#2928).\n- **OMO Recommended Models**: Synced OMO recommended models with upstream defaults and improved Fill Recommended feedback.\n- **ShengSuanYun Model IDs Prefixed for Routing**: ShengSuanYun (胜算云) presets now carry the vendor prefixes the upstream gateway requires — `anthropic/…`, `google/…`, and `openai/…` (e.g. `anthropic/claude-sonnet-4.6`, `google/gemini-3.1-pro-preview`) — across the Claude Code, Claude Desktop, Codex, Gemini, OpenCode, and OpenClaw presets, including the Claude Code routing env (`ANTHROPIC_MODEL` / `ANTHROPIC_DEFAULT_{HAIKU,SONNET,OPUS}_MODEL`), so they resolve to valid upstream models instead of failing to route.\n- **ClaudeAPI Model Test Re-Enabled**: Reclassified the ClaudeAPI preset (Claude Code and Claude Desktop) from `third_party` to `aggregator` so its model test button is no longer disabled by the third-party Claude gate; the partner star is unaffected since it is driven by `isPartner`, not category.\n- **About Version Check**: Version checks now handle prerelease tool versions without misclassifying update state.\n- **App Switcher Text Clipping**: Removed a fixed width constraint that clipped app-switcher text (#3161).\n- **useEffect Race Condition**: Added an active-flag pattern to App.tsx effects to prevent listener leaks on unmount, and guarded against storing `undefined` language in localStorage (#2827).\n\n### Removed\n\n- **LionCC Sponsor and Presets**: Removed the LionCC sponsor entry and LionCCAPI presets across READMEs, provider configs, and locales (icon asset retained).\n- **AICoding Partner Entry**: Removed the AICoding partner from README sponsor listings, provider presets, and i18n metadata.\n- **Kimi For Coding Codex Preset**: Removed the Kimi For Coding preset from the Codex preset catalog.\n- **CLI Uninstall Command Hints**: Dropped generated CLI uninstall command hints from the tool-management UI while keeping conflict diagnostics visible.\n\n### Docs\n\n- **Codex Chat Provider Support**: Documented Chat Completions routing, provider support, reasoning auto-detection, and Local Routing guidance in the changelog and user manual.\n- **Settings Manual Refresh**: Updated settings documentation for the new managed tool lifecycle and Hermes installer behavior.\n- **Claude Desktop Guide**: Added localized Claude Desktop guide pages and screenshots for provider setup, import, model mapping, and Local Routing context.\n- **Installation Docs**: Updated installation docs and READMEs to recommend the official Homebrew cask and refreshed the v3.15.0 release-note imposter-site warning wording across locales.\n\n## [3.15.0] - 2026-05-16\n\nDevelopment since v3.14.1 focuses on a dedicated Claude Desktop surface with third-party provider switching through a proxy gateway, a large reverse-proxy hardening pass (reliability, retries, cache, takeover, Gemini/Vertex/Codex paths), expansion of the third-party provider preset catalog (BytePlus / Volcengine / ClaudeAPI / ClaudeCN / RunAPI / RelaxyCode / PatewayAI / Baidu Qianfan), role-based model mapping with a 1M context flag, Codex OAuth live model discovery, and a long tail of usage, OAuth, Codex, and session quality-of-life fixes.\n\n**Stats**: 127 commits | 211 files changed | +17,980 insertions | -2,748 deletions\n\n### Highlights\n\n- **Claude Desktop becomes a first-class managed surface** with third-party provider switching through an in-app proxy gateway, role-based model mapping (sonnet / opus / haiku) with a 1M context flag, Copilot/Codex OAuth provider reuse, and 44 imported provider presets translated from the Claude Code catalog. Note: 20 Claude Desktop presets now default to direct mode instead of routing through the proxy — verify connectivity if you previously relied on proxy routing for these presets.\n- **Major reverse-proxy hardening**: P0–P3 lifecycle, retry, failover, and rectifier patches; pooled HTTPS reuse for non-Anthropic backends; Codex/Responses cache hit-rate improvements; correct Anthropic ↔ OpenAI `tool_choice` mapping; Vertex AI URL preservation; Gemini path-based model extraction; takeover detection refinement; IPv6 listen address support.\n- **Provider Ecosystem Expansion**: Added BytePlus, Volcengine Agentplan, ClaudeAPI, ClaudeCN, RunAPI, RelaxyCode, PatewayAI, and Baidu Qianfan Coding Plan partner presets; promoted DouBao Seed to partner status; routing-support badges now surface on provider cards.\n- **Role-Based Model Mapping for Claude Code**: Display-name-aware sonnet / opus / haiku route mapping with a `supports1m` flag replaces the legacy `[1M]` suffix and decouples routing from raw model IDs.\n- **Codex OAuth Live Model Discovery**: ChatGPT Codex providers now fetch the live model list from the ChatGPT backend on demand instead of relying on a static list.\n- **Usage Dashboard Filter-Driven Hero**: A new filter-driven Hero card with cache-normalized totals replaces the legacy summary block, paired with cache-cost-semantics fixes that silence a noisy pricing warning storm.\n- **DeepSeek tool-call reasoning and zero-usage final deltas**: DeepSeek tool calls now return `reasoning_content` alongside `tool_calls` (#2543), and the final `message_delta` event always includes a usage block (even when zero) so strict Anthropic clients no longer crash on `null` (#2485).\n\n### Added\n\n- **Claude Desktop Third-Party Provider Switching via Proxy Gateway**: Added a dedicated Claude Desktop surface that brokers third-party Claude providers through CC Switch's in-app proxy, with a routing-support badge for providers that need it, role-based model route mapping locked to `sonnet` / `opus` / `haiku`, Copilot/Codex OAuth provider reuse, a redesigned Claude Code import flow, app-switcher differentiation between \"Claude Code\" and \"Claude Desktop\", and 44 provider presets translated from the Claude Code catalog.\n- **Routing Support Badges on Provider Cards**: Provider cards in both Claude Code and Codex panels now show a routing-support badge so users can tell at a glance which providers can be served through Local Routing.\n- **Codex OAuth Live Model List**: ChatGPT Codex providers now fetch the current model list from the ChatGPT backend on demand, replacing the previously hardcoded selection.\n- **Role-Based Model Mapping with 1M Flag**: Claude Code model mapping is now role-based (`sonnet` / `opus` / `haiku`) with display names and a `supports1m` flag, replacing the legacy `[1M]` suffix to decouple routing from raw model IDs.\n- **Filter-Driven Usage Hero**: The usage dashboard's Hero summary is now filter-driven with cache-normalized totals so the figures line up with the active date range and provider filters.\n- **Provider Form \"Save Anyway\" Prompt**: Softened provider form validation with a \"save anyway\" prompt so non-blocking input issues no longer prevent saving (#2307).\n- **Universal Provider Duplicate Action**: Added a duplicate action for universal providers from the provider list (#2416).\n- **Persisted Tauri Window State**: Window position and size now persist across launches (#2377).\n- **Tray Icon Tooltip**: The system tray icon now shows a tooltip on hover for clearer at-a-glance state (#2417).\n- **Warp Terminal Session Launch**: Added support for launching Warp and executing a saved session inside it (#2466).\n- **DeepSeek `reasoning_content` for Tool Calls**: DeepSeek tool-calling responses now return `reasoning_content` together with `tool_calls` so callers can render both (#2543).\n- **Baidu Qianfan Coding Plan for Claude Code**: Added a Baidu Qianfan Coding Plan preset for Claude Code (#2322).\n- **Compshare Coding Plan Preset (Cross-App)**: Added Compshare Coding Plan preset across claude / codex / hermes / openclaw.\n- **Partner Provider Presets**: Added BytePlus, Volcengine Agentplan, ClaudeAPI, ClaudeCN, RunAPI, RelaxyCode, and PatewayAI provider presets; promoted DouBao Seed to partner status with refreshed endpoint and links.\n- **44 Claude Desktop Provider Presets**: Translated 44 provider presets from the Claude Code catalog into the new Claude Desktop surface.\n\n### Changed\n\n- **20 Claude Desktop Presets Switched from Proxy to Direct Mode**: 20 Claude Desktop presets now ship in direct mode instead of routing through the proxy by default, reducing setup friction for users who don't need proxy-specific compatibility shims. Verify connectivity if you previously relied on proxy routing for these presets.\n- **Claude Desktop Operational Notes**: Switching a Claude Desktop provider now writes CC Switch's managed 3P profile and requires restarting Claude Desktop to take effect; proxy-mode providers require CC Switch Local Routing to stay running.\n- **Failover / Local Routing Guardrails**: Failover controls now require the target app's Local Routing takeover to be enabled, and stopping only the proxy service is blocked while any app still depends on takeover state.\n- **Usage Accounting Semantics**: Usage summaries now report cache-normalized real total tokens and cache hit rate; historical token and cost totals may shift after deduplication and pricing recalculation, but should be more accurate.\n- **Provider Preset Rendering Order**: Provider preset lists now render in author-defined array order with partners prioritized at the top, replacing the previous implicit sort.\n- **Model Mapping Hint Copy Simplified**: `modelMappingOffHint` was rewritten as action-oriented copy across zh / en / ja.\n- **CC Switch Brand Surface Unified to ccswitch.io**: All in-app and README references now point at ccswitch.io as the sole official website; the release notes template also surfaces ccswitch.io.\n- **Theme Switch Simplified**: Removed the circular reveal animation; theme changes are now an instant cross-fade.\n- **Claude Code App Switcher Differentiation**: The app switcher now visually distinguishes \"Claude Code\" from \"Claude Desktop\" and uses the \"Claude Code\" label in the app visibility settings.\n- **CI: Claude Review on Opus 4.7**: Upgraded the Claude review GitHub Action to Opus 4.7, tuned the prompt to reduce nitpick noise, added an `@claude` review-only Code Action, pinned PR head SHA for checkout, and dropped a `--max-turns 5` limit.\n- **Dependency Bumps**: `actions/checkout` 4 → 6 (#2517), `pnpm/action-setup` 5 → 6 (#2518), `softprops/action-gh-release` 2 → 3 (#2519), `actions/stale` 9 → 10 (#2520).\n- **DeepSeek Presets Switched to V4**: DeepSeek presets now ship V4 (flash / pro) with refreshed pricing seeds.\n- **Codex 1M Context Toggle Hidden in Provider Edit Form**: The 1M context-window toggle is no longer surfaced in the Codex provider edit form to reduce knob count for a setting that has no effect in current Codex deployments.\n- **OpenClaudeCode Migrated to MicuAPI Domain**: Updated the OpenClaudeCode preset to the MicuAPI domain; refreshed Micu API links to `micuapi.ai`.\n- **CrazyRouter Endpoints Switched to `cn` Subdomain**: Updated CrazyRouter preset endpoints to the `cn` subdomain.\n- **RelaxyCode Custom Icon**: Switched RelaxyCode preset icon to a custom `relaxcode.png` asset.\n- **Kimi For Coding Doc URL**: Updated Kimi For Coding website URL to the `/code/docs/` path.\n- **SiliconFlow International Site Shows USD**: Balance display now correctly shows USD for the SiliconFlow international site (was incorrectly displaying CNY).\n\n### Fixed\n\n- **OpenAI Responses API Usage Parsing Robustness**: Hardened `build_anthropic_usage_from_responses()` and the Responses → Anthropic SSE translator so a missing or malformed upstream `usage` no longer produces `\"usage\": null` in `message_delta`. This unblocks strict Anthropic clients (notably the VSCode Claude Code extension) that crashed with \"Cannot read properties of null (reading 'output_tokens')\" against providers such as Codex OAuth and DashScope's `compatible-mode/v1/responses` endpoint. Added OpenAI field-name fallbacks (`prompt_tokens` / `completion_tokens`), null/empty/partial object handling, and preserved cache token fields even when input/output tokens are missing (#2422).\n- **Proxy Reliability Patches (P0–P3)**: Multiple rounds of routing, lifecycle, retry, and rectifier patches across the request-forwarder paths; extracted a shared `handle_rectifier_retry_failure` helper and a shared `auth_header_value` helper across provider adapters.\n- **Proxy: Pooled HTTPS Connection Reuse**: Non-Anthropic backends now reuse pooled HTTPS connections instead of opening a fresh TLS session per request, materially reducing per-request latency.\n- **Proxy: Forward Client HTTP Method**: The proxy forwards the client's actual HTTP method instead of hard-coding `POST`, so non-POST upstream endpoints (e.g. GET `/v1/models`) work correctly.\n- **Proxy: Per-Attempt Counters and `max_retries` Wiring**: Client-request counters moved out of the per-attempt loop, and `AppProxyConfig.max_retries` is now correctly wired into the request forwarder.\n- **Proxy: Failover Decision Refinements**: Refined failover decision logic in the forwarder so retryable / unretryable errors are classified more accurately.\n- **Proxy: Takeover Detection Tightening**: Tightened takeover detection and use fallback restore when disabling takeover so leftover state no longer strands a provider.\n- **Proxy: Anthropic ↔ OpenAI `tool_choice` Mapping**: Anthropic `tool_choice` is now correctly mapped to the OpenAI Chat nested form during format conversion.\n- **Proxy: Gemini Request Model Extraction**: Gemini request model is now correctly extracted from the URI path (not the body) so transformed traffic reports the right model.\n- **Proxy: Auth Header Error Handling**: `get_auth_headers` now returns `Result` instead of panicking on bad credentials.\n- **Proxy: IPv6 Listen Address Validation**: The Proxy panel now accepts IPv6 listen addresses.\n- **Proxy: Codex / Responses Cache Hit Rate**: Improved cache hit rate for Codex and OpenAI Responses requests by stabilizing cache key derivation; only emit `prompt_cache_key` when a real client-provided session identity is available so unrelated conversations no longer collapse onto a single key; canonicalize (sort) JSON keys in outgoing request bodies and `tool_call` arguments / `tool_result` content for byte-identical prefix-cache reuse; thread `session_id` into the usage logger for request correlation.\n- **Proxy: JSON Schema Underscore Fields Preserved**: Private-parameter filtering now preserves underscore-prefixed field names inside JSON Schema name maps such as `properties`, `patternProperties`, `definitions`, and `$defs`, so user-defined schema keys like `_id` and `_meta` survive the filter.\n- **Proxy: Read Tool Empty Pages**: Drop empty pages from `Read` tool inputs so providers don't reject the request (#2472).\n- **Proxy: Per-Request Hot-Path Trim**: Trimmed per-request hot-path work and database wait time.\n- **Proxy: Real Provider Model Names Under Takeover**: The Claude Code menu now exposes the real provider model names when running under takeover, instead of a stale alias.\n- **Proxy: Zero Usage in Final Message Delta**: The final `message_delta` event always includes a usage block (even when zero) so strict Anthropic clients no longer crash on `null` (#2485).\n- **Proxy: Streaming `message_delta` Deduplication**: Deduplicated streaming `message_delta` events that some upstreams emit twice (#2366).\n- **Proxy: Scoped `reasoning_content` Preserved for Tool Calls**: Tool-call paths now correctly preserve the scoped `reasoning_content` field during transformation; Kimi / Moonshot OpenAI Chat compatibility paths keep the field while generic OpenAI-compatible requests stay free of it (#2367).\n- **Proxy: Vertex AI Full URL Preserved**: Full Vertex AI URLs are no longer truncated during proxy forwarding (#2415).\n- **Proxy: Leading Billing Header Stripped from System Content**: Strips the leading billing-header content that some upstreams prepend to the system message (#2350).\n- **Proxy: Claude Auth Strategy from `ANTHROPIC_*` Env Var**: The Claude auth strategy is now derived from the actual `ANTHROPIC_*` env variable name rather than an opaque heuristic.\n- **Third-Party Claude Providers: Disable Model Test**: Model probing is now disabled for third-party Claude providers where the gateway doesn't implement `/v1/models` consistently.\n- **Model-Fetch: `/models` Subpath for Anthropic-Compatible Providers**: `/models` discovery now works for Anthropic-compatible subpath providers.\n- **Copilot: Claude Model ID Resolution Against Live `/models`**: Copilot-backed providers now resolve Claude model IDs against the live `/models` list to avoid stale ID mismatches.\n- **Codex: Skip `environment_context` Injection When Extracting Session Title**: Session title extraction no longer pulls in `environment_context` noise (#2439).\n- **Codex: Hide Subagent Sessions**: Codex subagent sessions are now hidden from the main session list (#2445).\n- **Codex Startup Live Import Duplication**: Fixed a duplicate-import bug in the Codex startup live-import path (#2590).\n- **Codex Provider Switch History Drift**: Switching the active Codex provider no longer changes existing session history (#2349).\n- **Codex Usage Log Message**: Corrected a misleading log message for Codex session usage (#2473).\n- **Claude: Persist Max Effort via Env**: `max` effort now correctly persists via the env variable on restart (#2493).\n- **Claude Desktop: Match Proxy Model Route Without `[1M]` Suffix**: Route matching no longer requires the legacy `[1M]` suffix.\n- **Claude Desktop: Provider Form Focus Loss**: Fixed an input that lost focus while editing in the Claude Desktop provider form.\n- **Claude Desktop: Spurious Proxy-Stopped Status Alert**: Removed an alert that fired spuriously when the proxy was intentionally stopped.\n- **Claude Desktop: Empty Toolbar Capsule Hidden**: Hides the empty toolbar capsule when Claude Desktop is the active app.\n- **UI: Monitor Badge Icon Centering**: Centered the Monitor badge icon in the app switcher.\n- **Linux: Theme Selection Segfault**: Prevented selecting a theme from causing a segfault on Linux (#2502).\n- **Terminal: iTerm Fallback on Cold Launch**: Prevented iTerm from being selected as a fallback on cold launch when not actually present (#2448).\n- **Config: Sort JSON Keys Alphabetically**: Config writes now sort JSON keys alphabetically for deterministic output (#2469).\n- **Import Existing Side-Effect Free**: Made \"import existing\" side-effect free (#2429).\n- **Coding Plan: Zhipu Weekly Tier by Reset Time**: Corrected the Zhipu weekly tier name to match the actual reset time (#2420).\n- **DashScope: Usage Parsing Robustness**: Hardened DashScope usage parsing so a malformed payload no longer crashes the VSCode Claude Code extension (#2425).\n- **Usage: Prevent Double-Counting Between Proxy and Session-Log Sources**: Deduplicated usage records sourced from both the proxy and session logs.\n- **Usage: Cache Cost Semantics + Pricing Warn Storm**: Corrected cache-cost semantics and silenced a noisy pricing warning storm that fired on every request.\n- **CI: Frontend Formatting and Linux Clippy Restored**: Restored frontend formatting and Linux clippy checks in CI.\n- **Proxy Test Helper Clippy Warning**: Fixed a clippy warning in the proxy test helper.\n\n### Removed\n\n- **Hermes Agent Usage Tracking Integration**: Removed the in-cycle Hermes Agent usage tracking integration after upstream behavior changes made it impractical to keep in sync. The integration was never enabled in any released version — a zero-cost rendering bug found during its development was fixed before the integration was rolled back.\n- **Theme Switch Circular Reveal Animation**: Removed the circular reveal animation used during theme switching; the animation caused jank on slower compositors and added little visible value.\n- **DDSHub Partner Integration**: Removed DDSHub as a partner preset and dropped the cross-link blurbs across READMEs.\n\n### Docs\n\n- **README Sponsor Refresh (zh / en / ja)**: Added BytePlus, ClaudeCN, RunAPI, and PatewayAI sponsor entries; cross-linked BytePlus and Volcengine entries; refreshed the Crazyrouter $2 credit claim flow, the Compshare blurb, the Right Code blurb, and other sponsor logos and listings; flattened the LionCC logo onto a white background; switched the Chinese README's sponsor logo to the Volcengine artwork; added Hermes Agent to the README subtitles.\n- **Release Notes Template**: Surfaces `ccswitch.io` in the release notes template.\n- **Brand Surface**: Documented `ccswitch.io` as the sole official website across READMEs and in-app references.\n\n## [3.14.1] - 2026-04-23\n\nDevelopment since v3.14.0 focuses on Codex OAuth stability, tray usage visibility, Skills import/install reliability, Gemini session restore paths, and simplifying Hermes configuration health handling.\n\n**Stats**: 13 commits | 48 files changed | +1,883 insertions | -808 deletions\n\n### Added\n\n- **Tray Usage Visibility**: System tray submenus now show cached usage for the current Claude / Codex / Gemini provider, including subscription and script-based usage summaries with utilization color markers. Tray-triggered refreshes are throttled, limited to visible apps, and synchronized back into React Query so the main window and tray share fresh usage data (#2184).\n- **Tray Coding-Plan Usage (Kimi / Zhipu / MiniMax)**: System tray now renders 5-hour + weekly window usage for Chinese coding-plan providers using the same `🟢 h12% w80%` two-window layout as official subscription badges (worst utilization drives the emoji). Creating a Claude provider whose `ANTHROPIC_BASE_URL` matches a known coding-plan host now auto-injects `meta.usage_script`, so the tray lights up without opening the Usage Script modal. Existing `usage_script` values are preserved on update.\n- **Codex OAuth FAST Mode**: Added an explicit FAST mode toggle for Codex OAuth-backed Claude providers. When enabled, converted Responses requests send `service_tier=\"priority\"` for lower latency; the toggle stays off by default to avoid unexpectedly increasing ChatGPT quota consumption (#2210).\n\n### Changed\n\n- **Session and Settings Layout Polish**: Hardened the scroll-area viewport with width containment to fix horizontal overflow, and tightened app bottom spacing plus settings footer spacing so long session/settings views fit more cleanly (#2201).\n\n### Removed\n\n- **Hermes Config Health Scanner**: Removed the in-app Hermes config health scanner, warning banner, `scan_hermes_config_health` command, `HermesHealthWarning` type, and `HermesWriteOutcome.warnings` payload. CC Switch now keeps the Hermes surface focused on active provider display, provider switching defaults, memory editing, and launching the Hermes Web UI for deep configuration.\n\n### Fixed\n\n- **Codex OAuth Cache Routing**: Stabilized ChatGPT Codex reverse-proxy cache identity by using client-provided session IDs for `prompt_cache_key` and Codex session headers, preserving explicit cache keys, and avoiding generated UUID cache churn (#2218).\n- **Codex OAuth Responses SSE Aggregation**: Non-streaming Anthropic clients now receive JSON even when the ChatGPT Codex upstream forces OpenAI Responses SSE; CC Switch aggregates the upstream SSE events before running the non-streaming transform (#2235).\n- **Codex OAuth Stream Check Parity**: Stream checks now build Codex OAuth test requests with the same `store: false`, encrypted reasoning include, and provider FAST mode setting as production proxy requests (#2210).\n- **Codex Model Extraction**: Replaced first-line regex matching with TOML parsing when reading Codex config models, so multiline TOML is handled correctly (#2227).\n- **Model Quick-Set / One-Click Config**: Model quick-set updates now apply against the latest provider form config, preventing stale state from making one-click configuration fail (#2249).\n- **Skills Import Duplicates**: The Skills import dialog disables actions while import is pending and the installed-skills cache deduplicates imported results by ID, preventing double-clicks from adding duplicate installed entries (#2139, #2211).\n- **Root-Level Skill Repos**: Skill install and update flows now consistently resolve three source patterns: direct nested paths, install-name recursive search, and repository-root `SKILL.md` sources (#2231).\n- **Gemini Session Restore Paths**: Gemini session scanning now reads `.project_root` metadata so restore flows can pass the original project directory when available (#2240).\n- **Provider Hover Names**: Provider icons now expose the provider name on hover for inline SVG, image URL, and fallback initials render paths (#2237).\n\n## [3.14.0] - 2026-04-21\n\nDevelopment since v3.13.0 focuses on onboarding Hermes Agent as a first-class managed app, rolling out Claude Opus 4.7 across the preset matrix, adding a Gemini Native API proxy, and sharpening session, usage, and proxy workflows.\n\n**Stats**: 100 commits | 219 files changed | +20,548 insertions | -3,569 deletions\n\n### Added\n\n- **Hermes Agent Support (6th Managed App)**: Added Hermes Agent as a first-class managed app with database migration v9→v10, full Rust command surface, YAML-backed `~/.hermes/config.yaml` read/write with atomic backups, MCP sync, Skills sync, session manager with SQLite + JSONL support, and dedicated frontend panels. Supports four API protocols (`chat_completions`, `anthropic_messages`, `codex_responses`, `bedrock_converse`) aligned with Hermes Agent 0.10.0 schema. Read-only rendering for providers owned by the user-authored `providers:` dict, with deep configuration delegated to the Hermes Web UI.\n- **Hermes Memory Panel**: Added a Memory panel for editing `MEMORY.md` and `USER.md` directly from CC Switch, with an enable switch, character-count limits, and a live save flow. Replaces the Prompts entry for Hermes.\n- **Hermes Provider Presets**: Added ~50 Hermes provider presets spanning Nous Research, Shengsuanyun, OpenRouter, DeepSeek, Together AI, StepFun, Zhipu GLM, Bailian, Kimi, MiniMax, DouBao, BaiLing, ModelScope, KAT-Coder, PackyCode, Cubence, AIGoCode, RightCode, AICodeMirror, AICoding, CrazyRouter, SSSAiCode, Micu, CTok.ai, DDSHub, E-FlowCode, LionCCAPI, PIPELLM, Compshare, SiliconFlow, AiHubMix, DMXAPI, TheRouter, Novita, Nvidia, and Xiaomi MiMo.\n- **Claude Opus 4.7 Support**: Added Claude Opus 4.7 with adaptive thinking whitelisting, per-million pricing seed, and Bedrock SKU (`anthropic.claude-opus-4-7` / `global.anthropic.claude-opus-4-7`, dropping the legacy `-v1` suffix). Migrated all aggregator and Bedrock presets to Opus 4.7 as the default Opus model.\n- **Claude `max` Effort Tier**: Upgraded the Claude effort dropdown from `high` to `max` for extended reasoning capacity.\n- **Gemini Native API Proxy**: Added `api_format = \"gemini_native\"` so the proxy can forward to Google's `generateContent` API with full streaming, schema conversion, and shadow request support. Adds `gemini_url.rs`, `gemini_schema.rs`, `gemini_shadow.rs`, `streaming_gemini.rs`, and `transform_gemini.rs` under the proxy providers module.\n- **GitHub Copilot Enterprise Server**: Added GHES authentication and endpoint configuration for Copilot-backed Claude providers, plus thinking-block stripping before upstream to preserve premium interaction quota.\n- **Session List Virtualization**: Virtualized the session list via `@tanstack/react-virtual` so long conversations (thousands of records) scroll smoothly; long session messages are now collapsed by default to reduce text layout cost.\n- **Codex / OpenClaw Session Title Extraction**: Added meaningful title auto-extraction for Codex and OpenClaw sessions with 2-line display; strips OpenClaw `message_id` suffix noise.\n- **Usage Date Range Picker**: Added a date range selector to the usage dashboard with preset tabs (Today / 1d / 7d / 14d / 30d), a custom date + time calendar picker, and a page-jump input on paginated lists.\n- **Model Mapping Quick-Set**: Added a quick-set button next to model mapping fields in provider forms for faster edits.\n- **Stream Check Error Classification**: Classified Stream Check errors and surfaced them as color-coded toasts; refreshed default probe models and added explicit detection for \"model not found\" responses.\n- **Block Official Provider Switching During Local Routing**: Blocks switching to official providers while Local Routing is active, since routing official API traffic through the local proxy carries account-suspension risk. A warning toast surfaces the block.\n- **Pricing Database Refresh (v8 → v9)**: Added ~50 new model pricing entries and corrected stale prices via a reseed-on-migration step, including Claude 4.7, Opus 4.7 Adaptive Thinking, Grok 4, Qwen 3.5/3.6, MiniMax M2.5/M2.7, Doubao Seed 2.0 series, and GLM-5/5.1. DeepSeek and Kimi K2.5 prices updated.\n- **Application-Level Window Controls**: Added an opt-in setting to render CC Switch's own minimize / toggle-maximize / close buttons instead of the system decorations, materially improving the experience on Linux Wayland where compositor-drawn buttons can become inert.\n- **Hermes in Unified Skills Management**: Added Hermes to the unified Skills surface; skill install, enable, and filter now cover the Hermes app alongside Claude / Codex / Gemini / OpenCode / OpenClaw.\n- **OpenClaw Config Directory Override**: Added a settings option to point CC Switch at a custom `openclaw.json` location.\n- **Hermes Config Directory Override**: Added a settings option to point CC Switch at a custom `~/.hermes/config.yaml` location, backed by data-driven dispatch.\n- **StepFun Step Plan Preset**: Added StepFun Step Plan (EN/ZH) provider presets.\n- **New API Usage Script Template**: Added a User-Agent header to the New API usage script template for better upstream compatibility.\n- **Launch Hermes Dashboard from Toolbar**: When the Hermes Web UI probe fails, the toolbar entry now offers to run `hermes dashboard` in the user's preferred terminal via a temp bash/batch script. `hermes dashboard` opens the browser itself once ready, so no polling is required. Also corrects the stale `hermes web` hint in the offline toast (the real command is `hermes dashboard`) and reorders Linux terminal detection to try `which` before stat'ing `/usr/bin`, `/bin`, `/usr/local/bin`.\n- **LemonData Provider Preset (All Six Apps)**: Registered LemonData as a third-party partner preset across Claude, Codex, Gemini, OpenCode, OpenClaw, and Hermes, with icon assets and zh/en/ja partner-promotion copy. Claude uses `ANTHROPIC_API_KEY` auth; OpenAI-compatible apps target `gpt-5.4`.\n- **DDSHub Codex Preset**: Added a Codex-compatible endpoint for DDSHub at the same host as its Claude service; base URL omits the `/v1` suffix because the gateway auto-routes OpenAI SDK paths.\n\n### Changed\n\n- **\"Local Proxy Takeover\" → \"Local Routing\"**: Unified terminology across UI copy, README, and docs in all three locales. Functional behavior is unchanged.\n- **Hermes `Auto` api_mode Removed**: Users must now pick an explicit protocol; new deeplinks default to `chat_completions`. Eliminates URL-based heuristic surprises.\n- **Hermes Provider Form**: Added an API mode dropdown and per-provider model editor; bound per-provider models to the top-level `model:` when switching active providers.\n- **Hermes Deep Config Delegation**: Deep YAML knobs are now delegated to the Hermes Web UI via a direct launch action, rather than duplicated in the CC Switch form.\n- **`ANTHROPIC_REASONING_MODEL` Removed from Claude Quick-Set**: Decoupled the reasoning capability from model selection; the legacy field is no longer surfaced in the quick-set form.\n- **Per-Provider Proxy Config Removed**: Consolidated into global Local Routing; the provider-level proxy toggle and associated storage are gone.\n- **Unified Toolbar Icon Button Width**: Normalized icon-button widths across Claude / Codex / Gemini / OpenCode / OpenClaw / Hermes panels for a consistent header look.\n- **Rust Toolchain Pinned to 1.95**: Adopted clippy 1.95 suggestions across the workspace and pinned the toolchain to prevent nightly drift.\n- **Tray Menu ID Constant**: The tray identifier moved from the hardcoded string `\"main\"` to a `TRAY_ID` constant (`\"cc-switch\"`) across all call sites.\n- **Copilot Request Classification**: Refined request routing inside the Copilot optimizer to further reduce unnecessary premium interaction consumption.\n- **Usage Script Intranet Support**: Removed private-IP / suspicious-hostname blocking from usage scripts, unblocking enterprise intranet, Docker, and self-hosted API endpoints. Built-in templates still enforce HTTPS (except localhost) and same-origin checks; custom templates remain user-controlled with those request-URL checks skipped.\n- **Failover Queue Notes**: Provider notes now appear in failover queue selectors and queue rows for easier identification across multi-provider queues.\n- **Hermes Toolbar Layout**: Swapped the Hermes Web UI button from `ExternalLink` to `LayoutDashboard` (clicking may spawn `hermes dashboard` rather than just opening a URL), and moved MCP to the final toolbar slot so Hermes matches the Claude / Codex / Gemini / OpenCode layout.\n\n### Fixed\n\n- **Header Auto-Compact Latching After Maximize**: The toolbar no longer stays compacted after maximize/restore; compaction now reevaluates on size changes.\n- **Hermes YAML Pollution & OAuth MCP Auth Drop**: Round-tripping through CC Switch no longer drops OAuth MCP `auth` blocks or pollutes unrelated YAML keys; guard tests added via `tests/hermes_roundtrip.rs`.\n- **Hermes Active Provider Display**: Hermes UI now correctly surfaces the active provider and wires add / enable / remove actions.\n- **Hermes Provider Persistence**: Providers persist under `custom_providers:` so `api_mode` and `model` survive restarts and config reloads.\n- **Codex `cache_control` Preservation**: Preserve `cache_control` when merging system prompts during Codex format conversion (#1946).\n- **Claude Prompt Cache Key Leak**: Stopped sending prompt cache keys during Claude chat conversions (#2003).\n- **Proxy Hop-by-Hop Header Stripping**: Strip hop-by-hop response headers (Connection, Keep-Alive, Transfer-Encoding, etc.) per RFC 7230.\n- **Permissive Proxy CORS Removed**: Removed the permissive CORS layer from the proxy (#1915).\n- **Copilot Premium Consumption**: Further reduced unnecessary Copilot premium interaction consumption during pass-through traffic.\n- **Backend Error Details in Proxy Toast**: Surface backend error payload details in proxy-related toast messages instead of a generic failure string.\n- **Usage Log Deduplication**: Deduplicated proxy and session-log usage records so the same request is no longer double-counted; synced the request log time range with the dashboard's 1d / 7d / 30d selector.\n- **Common Config Checkbox Persistence**: Checkbox state for Claude / Codex / Gemini common-config toggles now persists correctly across reopens.\n- **Claude Plugin `settings.json` Sync**: Editing the current provider now syncs back to `settings.json` for the Claude plugin path.\n- **Google Official Gemini Env Preservation**: Saving the Google Official Gemini provider no longer clobbers the `env` block.\n- **OpenCode JSON5 Parser for Trailing Commas**: OpenCode config reads now tolerate trailing commas via a JSON5 parser.\n- **Preset Refreshes**: Refreshed stale context windows for DeepSeek and Claude 1M; refreshed stale model IDs; backfilled Hermes model lists; fixed the Nous endpoint and replaced the Hermes placeholder icon with Nous brand artwork; pruned unused official Hermes presets.\n- **Auto-Expand Collapsed Messages on Search Hit**: Collapsed messages now auto-expand when a search match lands inside hidden content.\n- **Unknown Subscription Quota Tiers Hidden**: Provider cards no longer render unknown subscription quota tiers.\n- **Weekly Limit Label Unified**: Aligned the weekly_limit tier label with the official 7-day naming across locales.\n- **Root-Level Skill Repo Install**: Fixed skill installation when the repository root itself is a skill.\n- **Session ID Parsing Clippy**: Removed a redundant closure in session ID parsing (clippy warning).\n- **Usage Log Stat Dedup**: Deduplicated proxy-sourced and session-log-sourced usage records for accurate totals.\n- **Stream Check Default Models Refresh**: Updated stream-check default probe models to match each vendor's current lineup.\n- **Skills Import Sync**: Imported Skills are now immediately synced into enabled app directories instead of only being recorded in the database, so the UI no longer shows \"installed\" while the target app directory is missing the skill.\n- **Ghostty Session Restore**: Fixed Ghostty session restore launch by using shell execution with `--working-directory`, avoiding `cwd` escaping issues when the path contains spaces or special characters.\n- **Hermes Health Check Borrowing OpenClaw Schema**: Hermes providers were routed through `check_additive_app_stream` (the OpenClaw dispatcher), which reads camelCase `baseUrl` / `apiKey` / `api` and surfaced \"OpenClaw provider is missing baseUrl\" even when every Hermes field was filled. Introduced `check_hermes_stream` with Hermes-specific extractors that map `api_mode` (`chat_completions` / `anthropic_messages` / `codex_responses`) to the matching `check_claude_stream` `api_format`, and returns `bedrock_converse` as unsupported. `api_mode` is now resolved before URL / API key extraction, so `bedrock_converse` users see the real cause rather than a misleading \"missing base_url\".\n- **Usage Query Modal for Hermes & OpenClaw**: `getProviderCredentials` now reads flat `settingsConfig` fields for Hermes (snake_case `base_url` / `api_key`) and OpenClaw (camelCase `baseUrl` / `apiKey`), so the \"official balance\" template auto-selects for matching providers like SiliconFlow. Also refactored the BALANCE and TOKEN_PLAN test paths to reuse the precomputed `providerCredentials` instead of re-reading `env.ANTHROPIC_*` directly, fixing the \"empty key\" error for non-Claude apps even when the key was configured.\n\n### Docs\n\n- **README Sponsor Updates**: Updated SiliconFlow signup bonus to ¥16, trimmed the SSSAiCode sponsor blurb, updated partner logos, and added LemonData as a new sponsor.\n- **Global Proxy Hint Clarified**: Clarified the global proxy hint about local routing across all three locales.\n- **Takeover → Routing Rename**: Renamed takeover docs to routing and updated anchors across all languages.\n- **PIPELLM Website URL**: Updated the PIPELLM sponsor website URL to `code.pipellm.ai`.\n\n### Breaking\n\n- **Hermes requires explicit `api_mode`**: The `Auto` mode is gone; imported or deeplinked providers default to `chat_completions`. Users with prior `Auto` configs will be prompted to pick a protocol.\n- **`ANTHROPIC_REASONING_MODEL` removed from Claude quick-set**: The legacy field is no longer exposed; existing settings are cleaned up automatically.\n- **Per-provider proxy configuration removed**: Migrate to the global Local Routing setting. Existing per-provider proxy values are ignored.\n- **Database schema bumped v9 → v10**: Adds `enabled_hermes` columns to `mcp_servers` and `skills` (auto-migrated with `DEFAULT 0`; no data loss).\n- **Pricing table reseeded (v8 → v9)**: The `model_pricing` table is cleared and reseeded on first launch to pick up new models and corrected prices.\n- **XCodeAPI preset removed**: Users of the XCodeAPI preset should switch to another provider.\n\n---\n\n## [3.13.0] - 2026-04-10\n\nDevelopment since v3.12.3 focuses on quota visibility, provider workflow upgrades, stronger proxy compatibility, and lower-overhead tray / session workflows.\n\n### Added\n\n- **Lightweight Mode**: Added a tray-only mode that destroys the main window and keeps CC Switch running from the system tray, with the window recreated when users reopen it.\n- **Provider Model Auto-Fetch**: Added OpenAI-compatible `/v1/models` discovery for Claude, Codex, Gemini, OpenCode, and OpenClaw provider forms, including grouped dropdown selection and failure-specific error messages.\n- **Quota & Balance Visibility**: Added inline quota or balance display for official Claude / Codex / Gemini providers, GitHub Copilot premium interactions, Codex OAuth providers, Token Plan providers (Kimi / Zhipu GLM / MiniMax), and official balance queries for DeepSeek, StepFun, SiliconFlow, OpenRouter, and Novita AI. Copilot / ChatGPT OAuth and CLI subscription quota now only auto-poll for the currently active provider, preventing unnecessary API calls and misleading displays on non-current cards.\n- **Skills Discovery & Batch Updates**: Added SHA-256 based skill update detection, per-skill and batch update actions, a storage-location toggle between CC Switch and `~/.agents/skills`, and public `skills.sh` search integration.\n- **Session Workflow Upgrades**: Added batch delete in Session Manager, a directory picker before launching Claude terminal restore commands, usage import from Claude / Codex / Gemini session logs without requiring proxy interception, and per-app usage filtering for Claude / Codex / Gemini dashboards.\n- **Codex OAuth Reverse Proxy**: Added ChatGPT Plus / Pro based Codex OAuth reverse proxy support for Claude provider cards, including managed OAuth login and inline subscription quota display.\n- **OpenCode / OpenClaw Stream Check Coverage**: Added OpenCode npm package mapping plus support for OpenClaw `openai-completions` and the remaining OpenClaw protocol variants in Stream Check.\n- **Full URL Endpoint Mode**: Added a provider option that treats `base_url` as a complete upstream endpoint so proxy forwarding and stream checks can work with vendors that require nonstandard URL layouts.\n- **OpenCode StepFun Step Plan Preset**: Added a StepFun Step Plan provider preset for OpenCode.\n- **Copilot Interaction Optimizer**: Added request classification and routing logic to reduce unnecessary GitHub Copilot premium interaction consumption.\n- **First-Run Welcome Dialog**: Added a one-time welcome dialog on fresh installs explaining how existing configuration is preserved as a default provider and how the bundled official preset enables one-click revert. Upgrade users are excluded.\n- **Official Provider Seeding**: Added automatic seeding of Claude Official, OpenAI Official, and Google Official provider entries on startup, giving every user a one-click path back to the official endpoint.\n- **OpenCode / OpenClaw Auto-Import**: Added automatic startup import of live OpenCode and OpenClaw provider configurations, matching the auto-import behavior already present for Claude, Codex, and Gemini.\n- **Common Config Editor Guidance**: Added an informational guide and empty-state prompt to the Common Config snippet editor modal for Claude, Codex, and Gemini, with i18n support.\n- **Common Config First-Run Notice**: Added a one-time informational dialog explaining Common Config Snippets when users first open the provider add/edit form.\n- **Claude Session Titles**: Added meaningful title extraction for Claude sessions using a priority chain: custom-title metadata, first real user message, then directory basename fallback.\n- **Session Search Highlighting**: Added keyword highlighting in session titles and messages during Session Manager search.\n- **URL-Based Provider Icons**: Added a dual rendering mode to the icon system supporting Vite URL imports for large SVGs and raster images (PNG, JPG, WebP), keeping small SVGs inlined.\n- **Kaku Terminal Support**: Added Kaku as a selectable terminal for session launch on macOS, reusing the WezTerm-compatible launch path.\n- **OMO Slim Council Support**: Restored first-class council support as a built-in oh-my-opencode-slim agent with updated metadata and UI copy.\n- **TheRouter Provider Preset**: Added TheRouter provider presets across Claude, Codex, Gemini, OpenCode, and OpenClaw.\n- **DDSHub Provider Preset**: Added DDSHub as a third-party partner provider for Claude with icon and partner promotion text.\n- **LionCCAPI Provider Preset**: Added LionCCAPI as a third-party partner provider across all five apps with anthropic-messages protocol for OpenCode and OpenClaw.\n- **Shengsuanyun Provider Preset**: Added Shengsuanyun (胜算云) as an aggregator partner provider across all five apps with URL-based icon and localized display name.\n- **PIPELLM Provider Preset**: Added PIPELLM provider preset across Claude, Codex, OpenCode, and OpenClaw with full model definitions and icon.\n- **E-FlowCode Provider Preset**: Added E-FlowCode provider preset across all five apps with per-app protocol configuration.\n\n### Changed\n\n- **Tray Menu Organization**: Reworked the tray menu into per-app submenus to prevent overflow and make background provider switching scale better with larger provider lists.\n- **Proxy Forwarding Stack**: Refactored proxy forwarding onto a Hyper-based client with transparent header forwarding, improved endpoint rewriting, and better support for dynamic upstream endpoints.\n- **OAuth Auth Center UI Polish**: Tightened the Auth Center copy, layout, and icon presentation so the Codex OAuth login flow feels cleaner and less cluttered.\n- **Provider Key Lifecycle & Live Sync**: Reworked additive provider create / rename / duplicate flows so live config writes, cleanup, and rollback stay consistent across OpenCode / OpenClaw and takeover scenarios.\n- **Codex OAuth Defaults**: Updated the Codex OAuth preset to the GPT-5.4 model family.\n\n### Fixed\n\n- **Copilot Authentication & Proxy Compatibility**: Fixed GitHub Copilot authentication regressions, corrected enterprise / dynamic endpoint handling, repaired clipboard verification-code copying on macOS and Linux, and fixed Responses routing when Copilot-backed Claude providers target OpenAI models.\n- **Streaming Parser Compatibility**: Fixed SSE parsing to accept fields with optional spaces, improving compatibility with non-strict streaming implementations.\n- **UTF-8 Stream Chunk Boundaries**: Fixed intermittent garbled output (U+FFFD replacement characters) in Claude Code when multi-byte UTF-8 sequences such as Chinese characters or emoji were split across TCP stream chunks via the Copilot reverse proxy, by preserving incomplete trailing bytes across chunks in all four SSE streaming paths instead of lossy decoding.\n- **Fragmented System Prompt Normalization**: Fixed strict OpenAI-compatible chat backends (Nvidia, Qwen-style) rejecting requests when converted Claude payloads contained multiple system messages, by merging system content into a single leading system message during the Anthropic → OpenAI chat transformation.\n- **Provider Switch State Corruption**: Serialized per-app provider switches to prevent concurrent failover or hot-switch operations from leaving `is_current`, settings state, and live backup state out of sync.\n- **Claude Takeover Live Config Drift**: Fixed provider edits while Claude takeover is active so live settings remain aligned with the latest provider state without breaking takeover restore behavior.\n- **WebDAV Password Retention & Validation**: Fixed the WebDAV password field so saved credentials remain visible after refresh and treated `MKCOL 405` responses correctly during connection validation.\n- **Provider Card Action States**: Fixed additive-mode highlight behavior, aligned usage display layout across provider cards, replaced hard proxy-switch blocking with a warning path, and disabled unsupported test / usage actions for Copilot and Codex OAuth cards.\n- **Usage Accuracy & Pricing**: Fixed MiniMax quota math and 0%→100% progression, corrected CNY→USD pricing plus missing model definitions, improved Gemini session-log syncing, and resolved session-based usage entries being shown as unknown providers.\n- **Usage Editor & Skills UI Regressions**: Fixed usage query fields being reset while editing extractor code, corrected broken `skills.sh` links and empty descriptions, and fixed auto-query defaults plus number-input clearing in usage configuration.\n- **Chinese Skills Terminology**: Unified Skills-related labels across settings panels in the `zh` locale so storage and sync options use consistent wording.\n- **Environment & Preset Compatibility**: Added Bun global bin detection in CLI scan, adapted to the oh-my-openagent rename with backward compatibility, corrected the OpenCode `kimi-for-coding` preset, gated Gemini keychain parsing to macOS, and fixed an OpenClaw serializer panic on empty collections.\n- **Linux UI Unresponsive on Startup**: Fixed a bug where the window UI (including native title bar buttons) couldn't receive clicks on Linux until the user manually maximized and restored the window. Root causes: (1) Tauri webview did not acquire keyboard focus after `show()` on Linux, so the first click was consumed by X11/Wayland click-to-activate (Tauri #10746, wry #637); (2) GTK surface's input region failed to renegotiate on the `visible:false → show()` path under some WebKitGTK/compositor combinations, leaving the entire window unresponsive. Mitigations: set `WEBKIT_DISABLE_COMPOSITING_MODE=1` at startup, and added a new `linux_fix::nudge_main_window` helper that performs `set_focus` + a ±1px no-op resize ~200ms after show, equivalent to a visually invisible \"maximize-and-restore\". Wired into all window-re-show paths (normal startup, deeplink, single_instance, tray `show_main`, lightweight exit).\n- **Linux Drag Region on Header**: Removed `data-tauri-drag-region` from the top header bar on Linux to avoid triggering `gtk_window_begin_move_drag` paths affected by Tauri #13440 under Wayland. macOS drag behavior is preserved.\n- **OpenCode / OpenClaw Stream Check Edge Cases**: Fixed custom-header passthrough, OpenClaw custom auth-header detection, Bedrock error messaging, and OpenCode default `baseURL` fallback handling in Stream Check.\n- **Duplicate Toast on Provider Switch**: Fixed double toast notifications (proxy-required warning followed by switch-success) when switching to Copilot, ChatGPT, or OpenAI-format providers with the proxy not running.\n- **Session Search Accuracy & Chinese Support**: Fixed session search result truncation across providers and switched FlexSearch tokenizer to full mode for proper Chinese substring matching.\n- **Adaptive Thinking Reasoning Effort**: Fixed `resolve_reasoning_effort()` mapping adaptive thinking to `xhigh` instead of incorrectly using `high` in OpenAI format conversions.\n- **Thinking Model Fallback Display**: Fixed the Claude provider form showing an empty Thinking model field after saving only a main model by applying read-only fallback to ANTHROPIC_MODEL.\n- **Auth Tab Localization**: Fixed missing i18n translation keys for the settings auth tab label across all locale bundles.\n- **Schema Migration Guard**: Fixed database migrations failing when skills or model_pricing tables did not exist by adding table-existence checks before ALTER and UPDATE operations.\n\n### Docs\n\n- **User Manual Refresh**: Updated the EN / ZH / JA manuals for tray submenus, lightweight mode, provider model fetching, session management, workspace files, WebDAV v2 behavior, OpenCode / OpenClaw activation, and other provider workflow improvements.\n- **Community & Contribution Docs**: Added `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, bilingual issue / PR templates, Dependabot config, and CI quality checks.\n- **Release Notes Risk Notice**: Added a Copilot reverse proxy risk notice and anchored highlight links in the v3.12.3 release notes across all three languages.\n- **Sponsor Partners**: Added Shengsuanyun, LionCC, and DDS as sponsor partners in README across all languages.\n\n---\n\n## [3.12.3] - 2026-03-24\n\nMajor release adding GitHub Copilot reverse proxy support, macOS code signing & Apple notarization, intelligent reasoning effort mapping for o-series models, skill backup/restore lifecycle, proxy gzip compression, and critical fixes for WebDAV password safety, tool message parsing, and dark mode.\n\n**Stats**: 36 commits | 107 files changed | +9,124 insertions | -802 deletions\n\n### Added\n\n- **GitHub Copilot Reverse Proxy**: Full GitHub Copilot integration as a Claude Code provider via OAuth Device Code flow; includes multi-account management, automatic token refresh, Anthropic ↔ OpenAI format conversion, real-time model list fetching, and usage statistics (#930)\n- **Copilot Auth Center**: New Auth Center panel in Settings for managing GitHub accounts globally, with per-provider account binding via `meta.authBinding`\n- **Tool Search Toggle**: Added `ENABLE_TOOL_SEARCH` env var support for Claude 2.1.76+; exposed as a checkbox in the provider Common Config editor (#930)\n- **Reasoning Effort Mapping**: Two-tier `resolve_reasoning_effort()` for OpenAI o-series and GPT-5+ models — explicit `output_config.effort` takes priority, falling back to thinking `budget_tokens` thresholds (<4 000→low, 4 000–16 000→medium, ≥16 000→high); covers both Chat Completions and Responses API paths with 17 unit tests\n- **OpenCode SQLite Backend**: Added SQLite session storage support for OpenCode alongside existing JSON backend; dual-backend scan with SQLite priority on ID conflicts, atomic session deletion, and path validation (#1401)\n- **Skill Auto-Backup**: Skill files are automatically backed up to `~/.cc-switch/skill-backups/` before uninstall, with metadata preserved in `meta.json`; old backups pruned to keep at most 20\n- **Skill Backup Restore & Delete**: Added list/restore/delete commands for skill backups; restore copies files back to SSOT, saves the DB record, and syncs to the current app with rollback on failure\n- **macOS Code Signing & Notarization**: CI now imports an Apple Developer ID certificate, signs the universal binary, submits for Apple notarization, and staples the ticket to both `.app` and `.dmg`; a hard-fail verification step (`codesign --verify` + `spctl -a` + `stapler validate`) gates the release for both artifacts\n- **Codex 1M Context Window Toggle**: One-click checkbox in Codex config editor to set `model_context_window = 1000000` with auto-populated `model_auto_compact_token_limit = 900000`; unchecking removes both fields\n- **Disable Auto-Upgrade Toggle**: Added `DISABLE_AUTOUPDATER` env var checkbox in the Claude Common Config editor to prevent Claude Code from auto-upgrading\n\n### Changed\n\n- **Skills Cache Strategy**: Replaced `invalidateQueries` with direct `setQueryData` updates for skill install/uninstall/import operations; added `staleTime: Infinity` with `keepPreviousData` to eliminate loading flicker (#1573)\n- **Proxy Gzip Compression**: Non-streaming proxy requests now auto-negotiate gzip compression instead of forcing `identity`; streaming requests conservatively keep `identity` to avoid SSE decompression errors\n- **o1/o3 Model Compatibility**: Chat Completions proxy forwarding now correctly uses `max_completion_tokens` instead of `max_tokens` for OpenAI o-series models such as o1/o3/o4-mini (#1451)\n- **OpenCode Model Variants**: Placed OpenCode model variants at top level instead of inside options for better discoverability (#1317)\n- **Skills Import Flow**: Replaced implicit filesystem-based app inference with explicit `ImportSkillSelection` to prevent incorrect multi-app activation; added reconciliation to remove disabled/orphaned symlinks and MCP servers from live config\n- **Claude 4.6 Context Window**: Updated Claude Opus 4.6 and Sonnet 4.6 context window from 200K to 1M across OpenClaw and OpenCode presets (GA release)\n- **MiniMax Model Upgrade**: Updated MiniMax presets from M2.5 to M2.7 across Claude, OpenClaw, and OpenCode configurations with updated partner descriptions in all three locales\n- **Xiaomi MiMo Model Upgrade**: Updated MiMo presets from mimo-v2-flash to mimo-v2-pro across all supported applications\n- **AddProviderDialog Simplification**: Removed redundant OAuth tab, reducing dialog from 3 tabs to 2 (app-specific + universal)\n- **Provider Form Advanced Options Collapse**: Model mapping, API format, and other advanced fields in the Claude provider form now auto-collapse when empty; auto-expands when any value is set or when a preset fills them in\n\n### Fixed\n\n- **WebDAV Password Silent Clear**: Fixed WebDAV password being silently wiped when ProviderList or UsageScriptModal saved settings by stripping `webdavSync` from frontend payloads and adding backend backfill logic in `merge_settings_for_save()` to preserve existing passwords\n- **Tool Message Parsing**: Fixed tool_use/tool_result message classification across Claude (tool_result content blocks), Codex (function_call/function_call_output payloads), and Gemini (array content + toolCalls extraction) session providers (#1401)\n- **Dark Mode Selector**: Changed Tailwind `darkMode` from `[\"selector\", \"class\"]` to `[\"selector\", \".dark\"]` to ensure correct dark mode activation (#1596)\n- **Copilot Request Fingerprint**: Unified Copilot request fingerprint headers across all API call sites to prevent User-Agent leakage and stream check mismatches\n- **o-series Responses API Tokens**: Kept Responses API on the correct `max_output_tokens` field for o-series models instead of incorrectly injecting `max_completion_tokens`\n- **Provider Form Double Submit**: Prevented duplicate submissions on rapid button clicks in provider add/edit forms (#1352)\n- **Ghostty Session Restore**: Fixed Claude session restore in Ghostty terminal (#1506)\n- **Skill ZIP Import Extension**: Added `.skill` file extension support in ZIP import dialog (#1240, #1455)\n- **Skill ZIP Install Target App**: ZIP skill installs now use the currently active app instead of always defaulting to Claude\n- **OpenClaw Active Card Highlight**: Fixed active OpenClaw provider card not being highlighted (#1419)\n- **Responsive Layout with TOC**: Improved responsive design when TOC title exists (#1491)\n- **Import Skills Dialog White Screen**: Added missing TooltipProvider in ImportSkillsDialog to prevent runtime crash when opening the dialog\n- **Panel Bottom Blank Area**: Replaced hardcoded `h-[calc(100vh-8rem)]` with `flex-1 min-h-0` across all content panels to eliminate bottom gap caused by mismatched offset values\n\n### Docs\n\n- **Pricing Model ID Normalization**: Added documentation section explaining model ID normalization rules (prefix stripping, suffix trimming, `@`→`-` replacement) in EN/ZH/JA user manuals (#1591)\n- **macOS Signed & Notarized**: Removed all `xattr` workaround instructions and \"unidentified developer\" warnings from README, README_ZH, installation guides (EN/ZH/JA), and FAQ pages (EN/ZH/JA); replaced with \"signed and notarized by Apple\" messaging\n\n---\n\n## [3.12.2] - 2026-03-12\n\nPost-v3.12.1 work focuses on Common Config safety during proxy takeover and more reliable Codex TOML editing.\n\n**Stats**: 5 commits | 22 files changed | +1,716 insertions | -288 deletions\n\n### Added\n\n- **Empty State Guidance**: Improved first-run experience with detailed import instructions and a conditional Common Config snippet hint for Claude/Codex/Gemini providers\n\n### Changed\n\n- **Proxy Takeover Restore Flow**: Proxy takeover hot-switch and provider sync now refresh the restore backup instead of overwriting live config files, rebuilding effective provider settings with Common Config applied so rollback preserves the real user configuration\n- **Codex TOML Editing Engine**: Refactored Codex `config.toml` updates onto shared section-aware TOML helpers in Rust and TypeScript, covering `base_url` and `model` field edits across provider forms and takeover cleanup\n- **Common Config Initialization Lifecycle**: Startup now auto-extracts Common Config snippets from clean live configs before takeover restoration, tracks explicit \"snippet cleared\" state, and persists a one-time legacy migration flag to avoid repeated backfills\n\n### Fixed\n\n- **Common Config Loss During Takeover**: Fixed cases where proxy takeover could drop Common Config changes, overwrite live configs during sync, or produce incomplete restore snapshots when switching providers\n- **Codex Restore Snapshot Preservation**: Fixed Codex takeover restore backups so existing `mcp_servers` blocks survive provider hot-switches instead of being discarded; changed MCP backup preservation from wholesale table replacement to per-server-id merge so provider/common-config MCP updates win on conflict while live-only servers are retained\n- **Cleared Snippet Resurrection**: Fixed startup auto-extraction recreating Common Config snippets that users had intentionally cleared\n- **Codex `base_url` Misplacement**: Fixed Codex `base_url` extraction and editing to target the active `[model_providers.<name>]` section instead of appending to the file tail or confusing `mcp_servers.*.base_url` entries for provider endpoints\n\n---\n\n## [3.12.1] - 2026-03-12\n\n### Patch Release\n\nStability-focused patch release fixing the Common Config modal infinite reopen loop, a WebDAV sync foreign key constraint failure, several i18n interpolation issues, and a Windows toolbar compact mode bug. Also adds **StepFun** provider presets, **OpenClaw input type selection** and **authHeader** support, upgrades Gemini to **3.1-pro**, and welcomes four new sponsor partners.\n\n**Stats**: 19 commits | 56 files changed | +1,429 insertions | -396 deletions\n\n### Added\n\n#### Provider Presets\n\n- **StepFun**: Added StepFun (阶跃星辰) provider presets including the step-3.5-flash model across supported applications (#1369, thanks @hengm3467)\n\n#### OpenClaw Enhancements\n\n- **Input Type Selection**: Added input type selection dropdown for model Advanced Options in OpenClaw configuration form (#1368, thanks @liuxxxu)\n- **authHeader Field**: Added optional `authHeader` boolean to OpenClawProviderConfig for vendor-specific auth header support (e.g. Longcat), and refactored form state to reuse the shared type\n\n#### Sponsor Partners\n\n- **Micu API**: Added Micu API as sponsor partner with affiliate links\n- **XCodeAPI**: Added XCodeAPI as sponsor partner\n- **SiliconFlow**: Added SiliconFlow (硅基流动) as sponsor partner with affiliate links\n- **CTok**: Added CTok as sponsor partner\n\n### Changed\n\n- **UCloud → Compshare**: Renamed UCloud provider to Compshare (优云智算) with full i18n support across all three locales (EN/ZH/JA)\n- **Compshare Links**: Updated Compshare sponsor registration links to coding-plan page\n- **Gemini Model Upgrade**: Upgraded default Gemini model from 2.5-pro to 3.1-pro in provider presets\n\n### Fixed\n\n#### Common Config & UI\n\n- **Common Config Modal Loop**: Fixed an infinite reopen loop in the Common Config modal and added draft editing support to prevent data loss during edits\n- **Toolbar Compact Mode (Windows)**: Fixed toolbar compact mode not triggering on Windows due to left-side overflow (#1375, thanks @zuoliangyu)\n- **Session Search Index**: Fixed session search index not syncing with query data, causing stale list display after session deletion\n\n#### Sync & Data\n\n- **WebDAV Provider Health FK**: Fixed foreign key constraint failure when restoring `provider_health` table during WebDAV sync\n\n#### Provider & Preset\n\n- **Longcat authHeader**: Added missing `authHeader: true` to Longcat provider preset (#1377, thanks @wavever)\n- **OpenClaw Tool Permissions**: Aligned OpenClaw tool permission profiles with upstream schema (#1355, thanks @bigsongeth)\n- **X-Code API URL**: Corrected X-Code API URL from `www.x-code.cn` to `x-code.cc`\n\n#### i18n & Localization\n\n- **Stream Check Toast**: Fixed stream check toast i18n interpolation keys not matching translation placeholders\n- **Proxy Startup Toast**: Fixed proxy startup toast not interpolating address and port values (#1399, thanks @Mason-mengze)\n- **OpenCode API Format Label**: Renamed OpenCode API format label from \"OpenAI\" to \"OpenAI Responses\" for accuracy\n\n---\n\n## [3.12.0] - 2026-03-09\n\n### Feature Release\n\nThis release restores the **Model Health Check (Stream Check)** UI, adds **OpenAI Responses API** format conversion, introduces the **Bedrock Optimizer** for thinking + cache injection, expands provider presets (Ucloud, Micu, X-Code API, Novita, Bailian For Coding), overhauls **OpenClaw config panels** with a JSON5 round-trip write engine, enhances **WebDAV sync** with dual-layer versioning, and delivers a comprehensive **i18n audit** fixing 69 missing keys alongside 20+ bug fixes.\n\n**Stats**: 56 commits | 221 files changed | +20,582 insertions | -8,026 deletions\n\n### Added\n\n#### Stream Check (Model Health Check)\n\n- **Restore Stream Check UI**: Brought back the model health check (Stream Check) panel for testing provider endpoint availability with live streaming validation\n- **First-Run Confirmation**: Added a confirmation dialog on first use of Stream Check to inform users about the feature's purpose and network requests\n- **OpenAI Chat Format Support**: Stream Check now supports `openai_chat` api_format, enabling health checks for providers using OpenAI-compatible endpoints\n\n#### OpenAI Responses API\n\n- **Responses API Format Conversion**: New `api_format = \"openai_responses\"` option enabling Anthropic Messages ↔ OpenAI Responses API bidirectional conversion for providers that implement the Responses API\n- **Responses API Deduplication**: Deduplicated and improved the Responses API conversion logic, consolidating shared transformation code\n\n#### Bedrock Optimizer\n\n- **Bedrock Request Optimizer**: PRE-SEND optimizer that injects thinking parameters and cache control blocks into AWS Bedrock requests, enabling extended thinking and prompt caching on Bedrock endpoints (#1301)\n\n#### OpenClaw Enhancements\n\n- **JSON5 Round-Trip Write Engine**: Overhauled OpenClaw config panels with a JSON5 round-trip write engine that preserves comments, formatting, and ordering when saving configuration changes\n- **Config Panel Improvements**: Redesigned EnvPanel as a full JSON editor, added `tools.profile` selection to ToolsPanel, introduced OpenClawHealthBanner for config validation warnings, and added legacy timeout migration support in Agents Defaults\n- **Agent Model Dropdown**: Replaced text inputs with dropdown selects for OpenClaw agent model configuration, offering a curated list of available models\n- **User-Agent Toggle**: Added a User-Agent header toggle for OpenClaw, defaulting to off to avoid potential compatibility issues with certain providers\n\n#### Provider Presets\n\n- **Ucloud**: Added Ucloud partner provider preset for Claude, Codex, and OpenClaw with endpointCandidates, unified apiKeyUrl, refreshed model defaults, and OpenClaw `templateValues` / `suggestedDefaults`\n- **Micu**: Added Micu partner provider preset for Claude, Codex, OpenClaw, and OpenCode with OpenClaw `templateValues` / `suggestedDefaults`\n- **X-Code API**: Added X-Code API partner provider preset for Claude, Codex, and OpenCode with endpointCandidates\n- **Novita**: Added Novita provider presets and icon across all supported apps (#1192)\n- **Bailian For Coding**: Added Bailian For Coding preset configuration (#1263)\n- **SiliconFlow Partner Badge**: Added partner badge designation for SiliconFlow provider presets\n- **Model Role Badges**: Added model role badges (e.g., Opus, Sonnet) to provider presets and reordered presets to prioritize Opus models\n\n#### WebDAV Sync\n\n- **Dual-Layer Versioning**: Added protocol v2 + db-v6 dual-layer versioning to WebDAV sync, enabling backward-compatible sync format evolution and automatic migration detection\n- **Auto-Sync Confirmation**: Added a confirmation dialog when toggling WebDAV auto-sync on/off to prevent accidental changes\n\n#### Usage & Data\n\n- **Daily Rollups & Auto-Vacuum**: Added usage daily rollups for aggregated statistics, incremental auto-vacuum for storage management, and sync-aware backup that coordinates with WebDAV sync cycles\n- **UsageFooter Extra Fields**: Added extra field display in UsageFooter component for normal mode, showing additional usage metadata (#1137)\n\n#### Session Management\n\n- **Session Deletion**: Added session deletion with per-provider cleanup and path safety validation, allowing users to remove individual conversation sessions\n\n#### UI & Config\n\n- **Auth Field Selector**: Restored Claude provider auth field selector supporting both AUTH_TOKEN and API_KEY authentication modes\n- **Failover Toggle**: Moved failover toggle to display independently on the main page with a confirmation dialog for enabling/disabling\n- **Common Config Auto-Extract**: Auto-extract Common Config Snippets from live configuration files on first run, seeding initial common config without manual setup\n- **New Provider Page Improvements**: Improved the new provider page with API endpoint and model name fields (#1155)\n\n### Changed\n\n#### Architecture\n\n- **Common Config Runtime Overlay**: Common Config is now applied as a runtime overlay during provider switching instead of being materialized (merged) into each provider's stored config. This preserves the original provider config in the database and applies common settings dynamically at write time\n- **First-Run Auto-Extract**: On first run, Common Config Snippets are automatically extracted from the current live configuration files, eliminating the need for manual initial setup\n\n### Fixed\n\n#### Proxy & Streaming\n\n- **OpenAI Streaming Conversion**: Fixed OpenAI ChatCompletion → Anthropic Messages streaming conversion that could produce malformed events under certain response structures\n- **Codex /responses/compact Route**: Added support for Codex `/responses/compact` route in proxy forwarding (#1194)\n- **Codex Common Config TOML Merge**: Fixed Codex Common Config to use structural TOML merge/subset instead of raw string comparison, correctly handling key ordering and formatting differences\n- **Proxy Forwarder Failure Logs**: Improved proxy forwarder failure logging with more descriptive error messages\n\n#### Provider & Preset\n\n- **X-Code Rename**: Renamed \"X-Code\" provider to \"X-Code API\" for consistency with the official branding\n- **SSSAiCode Missing /v1**: Added missing `/v1` path to SSSAiCode default endpoint for Codex and OpenCode\n- **AICoding URL Fix**: Removed `www` prefix from aicoding.sh provider URLs to match the correct domain\n- **New Provider Page Input Handling**: Fixed the new provider page so API endpoint / model fields handle line-break deletion correctly and added the missing `codexConfig.modelNameHint` i18n key for zh/en/ja\n\n#### Platform\n\n- **Cache Hit Token Statistics**: Fixed missing token statistics for cache hits in streaming responses (#1244)\n- **Minimize-to-Tray Auto Exit**: Fixed issue where the application would automatically exit after being minimized to the system tray for a period of time (#1245)\n\n#### i18n & Localization\n\n- **Comprehensive i18n Audit**: Added 69 missing i18n keys and fixed hardcoded Chinese strings across the application, improving localization coverage for all three languages (zh/en/ja)\n- **Model Test Panel i18n**: Corrected i18n key paths for model test panel title and description\n- **JSON5 Slash Escaping**: Normalized JSON5 slash escaping and added i18n support for OpenClaw panel labels\n\n#### UI\n\n- **Skills Count Display**: Fixed skills count not displaying correctly when adding new skills (#1295)\n- **Endpoint Speed Test**: Removed HTTP status code display from endpoint speed test results to reduce visual noise\n- **Outline Button Text Tone**: Aligned outline button text color tone with usage refresh control for visual consistency (#1222)\n\n### Performance\n\n- **OpenClaw Config Write Skip**: Skip backup and atomic write when OpenClaw configuration content is unchanged, avoiding unnecessary I/O operations\n\n### Documentation\n\n- **User Manual i18n**: Restructured user manual for internationalization and added complete EN/JA translations alongside the existing ZH documentation\n- **User Manual OpenClaw**: Added OpenClaw coverage and completed settings documentation for the user manual\n- **UCloud CompShare Sponsor**: Added UCloud CompShare as a sponsor partner\n- **Docs Directory Reorganization**: Reorganized docs directory structure, added user manual links to all three README files, removed cross-language links from user manual sections, and synced README features across EN/ZH/JA\n\n### Maintenance\n\n- **Periodic Maintenance Timer**: Consolidated periodic maintenance timers into a unified scheduler, combining vacuum and rollup operations into a single timer\n- **OpenClaw Save Toast**: Removed backup path display from OpenClaw save toasts for cleaner notification messages\n\n---\n\n## [3.11.1] - 2026-02-28\n\n### Hotfix Release\n\nThis release reverts the Partial Key-Field Merging architecture introduced in v3.11.0, restoring the proven \"full config overwrite + Common Config Snippet\" mechanism, and fixes several UI and platform compatibility issues.\n\n**Stats**: 8 commits | 52 files changed | +3,948 insertions | -1,411 deletions\n\n### Reverted\n\n- **Restore Full Config Overwrite + Common Config Snippet** (revert 992dda5c): Reverted the partial key-field merging refactoring from v3.11.0 due to critical issues — non-whitelisted custom fields were lost during provider switching, backfill permanently stripped non-key fields from the database, and the whitelist required constant maintenance. Restores full config snapshot write, Common Config Snippet UI and backend commands, and 6 frontend components/hooks\n\n### Changed\n\n- **Proxy Panel Layout**: Moved proxy on/off toggle from accordion header into panel content area, placed directly above app takeover options, ensuring users see takeover configuration immediately after enabling the proxy\n- **Manual Import for OpenCode/OpenClaw**: Removed auto-import on startup; empty state now shows an \"Import Current Config\" button, consistent with Claude/Codex/Gemini behavior\n\n### Fixed\n\n- **\"Follow System\" Theme Not Auto-Updating**: Delegated to Tauri's native theme tracking (`set_window_theme(None)`) so the WebView's `prefers-color-scheme` media query stays in sync with OS theme changes\n- **Compact Mode Cannot Exit**: Restored `flex-1` on `toolbarRef` so `useAutoCompact`'s exit condition triggers correctly based on available width instead of content width\n- **Proxy Takeover Toast Shows {{app}}**: Added missing `app` interpolation parameter to i18next `t()` calls for proxy takeover enabled/disabled messages\n- **Windows Protocol Handler Side Effects**: Disabled environment check and one-click install on Windows to prevent unintended protocol handler registration\n\n---\n\n## [3.11.0] - 2026-02-26\n\n### Feature Release\n\nThis release introduces **OpenClaw** as the fifth supported application, a full **Session Manager** for browsing conversation history across all apps, an independent **Backup Management** panel, **Oh My OpenCode (OMO)** integration, and 50+ other features, fixes, and improvements across 147 commits.\n\n**Stats**: 147 commits | 274 files changed | +32,179 insertions | -5,467 deletions\n\n### Added\n\n#### OpenClaw Support (New Application)\n\n- **OpenClaw Integration**: Full management support for OpenClaw as the fifth application in CC Switch, including provider switching, configuration panels (Env / Tools / Agents Defaults), Workspace file management (HEARTBEAT / BOOTSTRAP / BOOT), daily memory files, and additive overlay mode\n- **OpenClaw Provider Presets**: 13+ built-in provider presets with brand icon and complete i18n (zh/en/ja)\n- **OpenClaw Form Fields**: Dedicated provider form with providerKey input, model allowlist auto-registration, and default model button\n- **OpenClaw Config Panels**: Env editor, Tools editor, and Agents Defaults editor backed by JSON5 read/write (`openclaw_config.rs`)\n\n#### Session Manager\n\n- **Session Manager**: Browse and search conversation history for Claude Code, Codex, Gemini CLI, OpenCode, and OpenClaw with table-of-contents navigation and in-session search\n- **Session App Filter**: Auto-filter sessions by current app when entering the session page\n- **Session Performance**: Parallel directory scanning and head-tail JSONL reading for faster session list loading\n\n#### Backup Management\n\n- **Backup Panel**: Independent backup management panel with configurable backup policy (max count, auto-cleanup) and backup rename support\n- **Periodic Backup**: Hourly automatic backup timer during runtime\n- **Pre-Migration Backup**: Automatic backup before database schema migrations with backfill warning\n- **Delete Backup**: Delete individual backup files with confirmation dialog\n- **Backup Time Fix**: Use local time instead of UTC for backup file names\n\n#### Oh My OpenCode (OMO)\n\n- **OMO Integration**: Full Oh My OpenCode config file management with agent model selection, category configuration, and recommended model fill\n- **OMO Slim**: Lightweight oh-my-opencode-slim mode support with OmoVariant parameterization\n- **OMO Cross-Exclusion**: Enforce OMO ↔ OMO Slim mutual exclusion at the database level\n\n#### Workspace\n\n- **Daily Memory Search**: Full-text search across daily memory files with date-sorted display\n- **Clickable Paths**: Directory paths in workspace panels are now clickable; renamed “Today's Note” to “Add Memory”\n- **Workspace Files Panel**: Manage bootstrap markdown files for OpenClaw (HEARTBEAT / BOOTSTRAP / BOOT types)\n\n#### Provider Presets\n\n- **AWS Bedrock**: Support for AKSK and API Key authentication modes (Claude and OpenCode)\n- **SSAI Code**: Partner provider preset across all five apps\n- **CrazyRouter**: Partner provider preset with custom icon\n- **AICoding**: Partner provider preset with i18n promotion text\n- **Bailian**: Renamed from Qwen Coder with new icon; updated domestic model providers to latest versions\n\n#### Proxy & Network\n\n- **Thinking Budget Rectifier**: New rectifier for thinking budget parameters with dedicated module (`thinking_budget_rectifier.rs`)\n- **WebDAV Auto Sync**: Automatic periodic sync with large file protection mechanism\n\n#### UI & UX\n\n- **Theme Animation**: Circular reveal animation when toggling between light and dark themes\n- **Claude Quick Toggles**: Quick toggle switches in the Claude config JSON editor for common settings\n- **Dynamic Endpoint Hint**: Context-aware hint text in endpoint input based on API format selection\n- **AppSwitcher Auto Compact**: Automatically collapse to compact mode based on available width, with smooth transition animation\n- **App Transition**: Fade-in/fade-out animation when switching between OpenClaw and other apps\n- **Silent Startup Conditional**: Show silent startup option only when launch-on-startup is enabled\n\n#### Settings & Environment\n\n- **First-Run Confirmation**: Confirmation dialogs for proxy and usage features on first use\n- **Local Proxy Toggle**: `enableLocalProxy` setting to control proxy UI visibility on the home page\n- **Environment Check**: More granular local environment detection (installed CLI tool versions, Volta path detection)\n\n#### Usage & Pricing\n\n- **Usage Dashboard Enhancement**: Auto-refresh control, robust formatting, and request log table improvements\n- **New Model Pricing**: Added pricing data for claude-opus-4-6 and gpt-5.3-codex with incremental data seeding\n\n### Changed\n\n#### Architecture\n\n- **Partial Key-Field Merging (⚠️ Breaking, reverted in v3.11.1)**: Provider switching now uses partial key-field merging instead of full config overwrite, preserving user's non-provider settings (plugins, MCP, permissions). The \"Common Config Snippet\" feature has been removed as it is no longer needed. Removes 6 frontend files and ~150 lines of backend dead code (#1098)\n- **Manual Import**: Replaced auto-import on startup with manual “Import Current Config” button in empty state, reducing ~47 lines of startup code\n- **OMO Variant Parameterization**: Eliminated ~250 lines of OMO/OMO Slim code duplication via `OmoVariant` struct with STANDARD/SLIM constants\n- **OMO Common Config Removal**: Removed the two-layer merge system for OMO common config (-1,733 lines across 21 files)\n\n#### Code Quality\n\n- **ProviderForm Decomposition**: Extracted ProviderForm.tsx from 2,227 lines to 1,526 lines by splitting into 5 focused modules (opencodeFormUtils, useOmoModelSource, useOpencodeFormState, useOmoDraftState, useOpenclawFormState)\n- **Shared MCP/Skills Components**: Extracted AppCountBar, AppToggleGroup, and ListItemRow shared components to eliminate duplication across MCP and Skills panels\n- **OpenClaw TanStack Query Migration**: Migrated Env, Tools, and AgentsDefaults panels from manual useState/useEffect to centralized TanStack Query hooks\n\n#### Settings Layout\n\n- **Proxy Tab**: Split Advanced tab into dedicated Proxy tab (local proxy, failover, rectifiers, global outbound proxy); moved pricing config to Usage dashboard as collapsible accordion. SettingsPage reduced from ~716 to ~426 lines with 5-tab layout: General | Proxy | Advanced | Usage | About\n- **Data Section Split**: Split data accordion into Import/Export and Cloud Sync sections for better discoverability\n\n#### Terminal & Config\n\n- **Unified Terminal Selection**: Consolidated terminal preference to global settings; added WezTerm support and terminal name mapping (iterm2 → iterm)\n- **OpenClaw Agents Panel**: Primary model field set to read-only; detailed model fields (context window, max tokens, reasoning, cost) moved to advanced options\n- **Claude Model Update**: Updated Claude model references from 4.5 to 4.6 across all provider presets\n\n### Fixed\n\n#### Critical\n\n- **Windows Home Dir Regression**: Restored default home directory resolution on Windows to prevent providers/settings “disappearing” when `HOME` env var differs from the real user profile directory (Git/MSYS environments); auto-detects v3.10.3 legacy database location\n- **Linux White Screen**: Disabled WebKitGTK hardware acceleration on AMD GPUs (Cezanne/Radeon Vega) to prevent EGL initialization failure causing blank screen on startup\n- **OpenAI Beta Parameter**: Stopped appending `?beta=true` to OpenAI Chat Completions endpoints, fixing request failures for Nvidia and other `apiFormat=”openai_chat”` providers\n- **Health Check Auth Mode**: Health check now respects provider's auth_mode setting instead of always using x-api-key header\n\n#### Provider & Preset\n\n- **OpenClaw /v1 Prefix**: Removed /v1 prefix from OpenClaw anthropic-messages presets to prevent double path (/v1/v1/messages) with Anthropic SDK auto-append\n- **Opus Pricing**: Corrected Opus pricing from $15/$75 to $5/$25 and upgraded model ID to claude-opus-4-6\n- **AIGoCode URLs**: Unified API base URL to https://api.aigocode.com across all apps; removed trailing /v1 suffix\n- **Zhipu GLM**: Removed outdated partner status from Claude, OpenCode, and OpenClaw presets\n- **API Key Visibility**: Restored API Key input field when creating new Claude providers (was incorrectly hidden for non-cloud_provider categories)\n\n#### OMO / OMO Slim\n\n- **OMO Slim Category Checks**: Added missing omo-slim category checks across add/form/mutation paths\n- **OMO Slim Cache Invalidation**: Invalidate OMO Slim query cache after provider mutations to prevent stale UI state\n- **OMO Recommended Models**: Synced agent/category recommended models with upstream sources; fixed provider/model format to pure model IDs\n- **OMO Fill Feedback**: Added toast feedback when “Fill Recommended” button silently fails\n- **OMO Last-Provider Restriction**: Removed last-provider deletion restriction for OMO/OMO Slim plugins\n- **OpenCode Model Validation**: Reject saving OpenCode providers without at least one configured model\n\n#### OpenClaw\n\n- **OpenClaw P0-P3 Fixes**: Fixed 25 missing i18n keys, replaced key={index} with stable crypto.randomUUID(), excluded openclaw from ProxyToggle/FailoverToggle, added deep link merge_additive_config(), unified serde(flatten) naming, added directory existence checks, removed dead code, added duplicate key validation\n- **OpenClaw Robustness**: Fixed EnvPanel visibleKeys using entry key names instead of array indices; added NaN guards; validated provider ID and model before import\n- **OpenClaw i18n Dedup**: Merged duplicate openclaw i18n keys to restore provider form translations\n\n#### Platform\n\n- **Window Flash**: Prevented window flicker on silent startup (Windows)\n- **Title Bar Theme**: Title bar now follows dark/light mode theme changes\n- **Skills Path Separator**: Fixed path separator matching for skill installation status on Windows (supports both `/` and `\\`)\n- **WSL Conditional Compilation**: Added `#[cfg(target_os = “windows”)]` to WSL helper functions to eliminate dead_code warnings on non-Windows platforms\n\n#### UI\n\n- **Toolbar Clipping**: Removed toolbar height limit that was clipping AppSwitcher\n- **Update Badge**: Show update badge instead of green check when a newer version is available\n- **Session Button Visibility**: Only show Session Manager button for Claude and Codex apps\n- **Directory Spacing**: Added vertical spacing between directory setting sections\n- **Dark Mode Cards**: Unified SQL import/export card styling in dark mode\n- **OpenClaw Scroll**: Enabled scrolling for OpenClaw configuration panel content\n\n#### i18n & Localization\n\n- **Session Manager i18n**: Replaced hardcoded Chinese strings with i18n keys for relative time, role labels, and UI elements\n- **OpenClaw Default Model Label**: Renamed “Enable/Default” to “Set as Default / Current Default” with wider button\n- **Daily Memory Sort**: Sort daily memory files by filename date (YYYY-MM-DD.md) instead of modification time\n- **Backup Name i18n**: Use local time for backup file names\n\n#### Other\n\n- **Skill Doc URL**: Use actual branch from download_repo for documentation URL; switched from /tree/ to /blob/ pointing to SKILL.md\n- **OpenCode Install Detection**: Added install.sh priority paths (OPENCODE_INSTALL_DIR > XDG_BIN_DIR > ~/bin > ~/.opencode/bin) with path dedup and cross-platform executable candidates\n- **Provider Auto-Import**: Removed auto-import side effect from useProvidersQuery queryFn; users now trigger import manually via empty state button\n- **Manual Backup Validation**: Treat missing database file as error during manual backup to prevent false success toast\n\n### Performance\n\n- **Session Panel Loading**: Parallel directory scanning and head-tail JSONL reading for Codex, OpenClaw, and OpenCode session providers\n- **Query Cache Cleanup**: Removed unnecessary TanStack Query cache overhead for Tauri local IPC calls\n\n### Documentation\n\n- **Sponsors**: Added/updated SSSAiCode, Crazyrouter, AICoding, Right Code, and MiniMax sponsor entries across all README languages\n- **User Manual**: Added user manual documentation (#979)\n\n### Maintenance\n\n- **Pre-Release Cleanup**: Removed debug logs, fixed clippy warnings, added missing Japanese translations, and formatted code\n- **UI Exclusions**: Hidden MCP, Skills, proxy/pricing, stream check, and model test panels for OpenClaw where not applicable\n\n---\n\n## [3.10.3] - 2026-01-30\n\n### Feature Release\n\nThis release introduces a generic API format selector, pricing configuration enhancements, and multiple UX improvements.\n\n### Added\n\n- **API Key Link for OpenCode**: API key link support for OpenCode provider form, enabling quick access to provider key management pages\n- **AICodeMirror Partner Preset**: Added AICodeMirror partner preset for all apps (Claude, Codex, Gemini, OpenCode)\n- **API Format Selector**: Generic API format chooser for Claude providers, replacing the OpenRouter-specific toggle. Supports Anthropic Messages (native) and OpenAI Chat Completions format\n- **API Format Presets**: Allow preset providers to specify API format (anthropic or openai_chat) for third-party proxy services\n- **Proxy Hint**: Display info toast when switching to OpenAI Chat format provider, reminding users to enable proxy\n- **Pricing Config Enhancement**: Per-provider cost multiplier, pricing model source (request/response), request model logging, and enriched usage UI (#781)\n- **Skills ZIP Install**: Install skills directly from local ZIP files with recursive scanning support\n- **Preferred Terminal**: Choose preferred terminal app per platform (macOS: Terminal.app/iTerm2/Alacritty/Kitty/Ghostty; Windows: cmd/PowerShell/Windows Terminal; Linux: GNOME Terminal/Konsole/Xfce4/Alacritty/Kitty/Ghostty)\n- **Silent Startup**: Option to prevent window popup on launch (#713)\n- **OpenCode Environment Check**: Version detection with Go path scanning and one-click install from GitHub Releases\n- **OpenCode Directory Sync**: Auto-sync all providers to live config on directory change with additive mode support\n- **NVIDIA NIM Preset**: New provider preset for Claude and OpenCode with nvidia.svg icon\n- **n1n.ai Preset**: New provider preset (#667)\n- **Update Badge Icon**: Replace update badge dot with ArrowUpCircle icon\n- **Linux ARM64**: CI build support for Linux ARM64 architecture\n\n### Changed\n\n- **API Format Migration**: Migrate api_format from settings_config to ProviderMeta to prevent polluting ~/.claude/settings.json\n- **DeepSeek max_tokens**: Remove max_tokens clamp from proxy transform layer\n- **Terminal Functions**: Consolidate redundant terminal launch functions\n- **Home Dir Utility**: Consolidate get_home_dir into single public function\n- **Kimi/Moonshot**: Upgrade provider presets to k2.5 model\n\n### Fixed\n\n- **Codex 404 & Timeout**: Fix 404 errors and connection timeout with custom base_url; improve /v1 prefix handling and system proxy detection (#760)\n- **Proxy URL Building**: Fix duplicate /v1/v1 in URL; extend ?beta=true to /v1/chat/completions endpoint\n- **OpenRouter Compat Mode**: Improve backward compatibility supporting number and string types\n- **Gemini Visibility**: Correct Gemini default visibility to true (#818)\n- **Footer Layout**: Correct footer layout in advanced settings tab\n- **Claude Code Detection**: Prioritize native install path for detection\n- **Tray Menu**: Simplify title labels and optimize menu separators (#796)\n- **Duplicate Skills**: Prevent duplicate skill installation from different repos (#778)\n- **Windows Tests**: Stabilize test environment (#644)\n- **i18n**: Update apiFormatOpenAIChat label to mention proxy requirement\n- **Error Display**: Use extractErrorMessage for complete error display in mutations\n- **Sponsors**: Add AICodeMirror and reorder sponsor list\n\n---\n\n## [3.10.2] - 2026-01-24\n\n### Patch Release\n\nThis maintenance release adds skill sync options and includes important bug fixes.\n\n### Added\n\n- **Skills**: Add skill sync method setting with symlink/copy options\n- **Partners**: Add RightCode as official partner\n\n### Fixed\n\n- **Prompts**: Clear prompt file when all prompts are disabled\n- **OpenCode**: Preserve extra model fields during serialization\n- **Provider Form**: Backfill model fields when editing Claude provider\n\n---\n\n## [3.10.1] - 2026-01-23\n\n### Patch Release\n\nThis maintenance release includes important bug fixes for Windows platform, UI improvements, and code quality enhancements.\n\n### Added\n\n- **Provider Icons**: Updated RightCode provider icon with improved visual design\n\n### Changed\n\n- **Proxy Rectifier**: Changed rectifier default state to disabled for better stability\n- **Window Settings**: Reordered window settings and updated default values for improved UX\n- **UI Layout**: Increased app icon collapse threshold from 3 to 4 icons\n- **Code Quality**: Simplified `RectifierConfig` implementation using `#[derive(Default)]`\n\n### Fixed\n\n- **Windows Platform**:\n - Fixed terminal window closing immediately after execution on Windows\n - Corrected OpenCode config path resolution on Windows\n- **UI Improvements**:\n - Fixed ProviderIcon color validation to prevent black icons from appearing\n - Unified layout padding across all panels for consistent spacing\n - Fixed panel content alignment with header constraints\n- **Code Quality**: Resolved Rust Clippy warnings and applied consistent formatting\n\n---\n\n## [3.10.0] - 2026-01-21\n\n### Feature Release\n\nThis release introduces OpenCode support and brings improvements across proxy, usage tracking, and overall UX.\n\n### Added\n\n- **OpenCode Support** - Manage OpenCode providers, MCP servers, and Skills, with first-launch import and full internationalization (#695)\n- **Global Proxy** - Add global proxy settings for outbound network requests (#596)\n- **Claude Rectifier** - Add thinking signature rectifier for Claude API (#595)\n- **Health Check Enhancements** - Configurable prompt and CLI-compatible requests for stream health check (#623)\n- **Per-Provider Config** - Support provider-specific configuration and persistence (#663)\n- **App Visibility Controls** - Show/hide apps and keep tray menu in sync (Gemini hidden by default)\n- **Takeover Compact Mode** - Use a compact AppSwitcher layout when showing 3+ visible apps\n- **Keyboard Shortcut** - Press `ESC` to quickly go back/close panels (#670)\n- **Terminal Improvements** - Provider-specific terminal button, `fnm` path support, and safer cross-platform launching (#564)\n- **WSL Tool Detection** - Detect tool versions in WSL with additional security hardening (#627)\n- **Skills Presets** - Add `baoyu-skills` preset repo and auto-supplement missing default repos\n\n### Changed\n\n- **Proxy Logging** - Simplify proxy log output (#585)\n- **Pricing Editor UX** - Unify pricing edit modal with `FullScreenPanel`\n- **Advanced Settings Layout** - Move rectifier section below failover for better flow\n- **OpenRouter Compat Mode** - Disable OpenRouter compatibility mode by default and hide UI toggle\n\n### Fixed\n\n- **Auto Failover** - Switch to P1 immediately when enabling auto failover\n- **Provider Edit Dialog** - Fix stale data when reopening provider editor after save (#654)\n- **Deeplink** - Support multiple endpoints and prioritize `GOOGLE_GEMINI_BASE_URL` over `GEMINI_BASE_URL` (#597)\n- **MCP (WSL)** - Skip `cmd /c` wrapper for WSL target paths (#592)\n- **Usage Templates** - Add variable hints and validation fixes; prevent config leaking between providers (#628)\n- **Gemini Timeout Format** - Convert timeout params to Gemini CLI format (#580)\n- **UI** - Fix Select dropdown rendering in `FullScreenPanel`; auto-apply default icon color when unset\n- **Usage UI** - Auto-adapt usage block offset based on action buttons width (#613)\n- **Provider Endpoint** - Persist endpoint auto-select state (#611)\n- **Provider Form** - Reset baseUrl and apiKey states when switching presets\n\n---\n\n## [3.9.1] - 2026-01-09\n\n### Bug Fix Release\n\nThis release focuses on stability improvements and crash prevention.\n\n### Added\n\n- **Crash Logging** - Panic hook captures crash info to `~/.cc-switch/crash.log` with full stack traces (#562)\n- **Release Logging** - Enable logging for release builds with automatic rotation (keeps 2 most recent files)\n- **AIGoCode Icon** - Added colored icon for AIGoCode provider preset\n\n### Fixed\n\n- **Proxy Panic Prevention** - Graceful degradation when HTTP client initialization fails due to invalid proxy settings; falls back to no_proxy mode (#560)\n- **UTF-8 Safety** - Fix potential panic when masking API keys or truncating logs containing multi-byte characters (Chinese, emoji, etc.) (#560)\n- **Default Proxy Port** - Change default port from 5000 to 15721 to avoid conflict with macOS AirPlay Receiver (#560)\n- **Windows Title** - Display \"CC Switch\" instead of default \"Tauri app\" in window title\n- **Windows/Linux Spacing** - Remove extra 28px blank space below native titlebar introduced in v3.9.0\n- **Flatpak Tray Icon** - Bundle libayatana-appindicator for tray icon support on Flatpak (#556)\n- **Provider Preset** - Correct casing from \"AiGoCode\" to \"AIGoCode\" to match official branding\n\n---\n\n## [3.9.0] - 2026-01-07\n\n### Stable Release\n\nThis stable release includes all changes from `3.9.0-1`, `3.9.0-2`, and `3.9.0-3`.\n\n### Added\n\n- **Local API Proxy** - High-performance local HTTP proxy for Claude Code, Codex, and Gemini CLI (Axum-based)\n- **Per-App Takeover** - Independently route each app through the proxy with automatic live-config backup/redirect\n- **Auto Failover** - Circuit breaker + smart failover with independent queues and health tracking per app\n- **Universal Provider** - Shared provider configurations that can sync to Claude/Codex/Gemini (ideal for API gateways like NewAPI)\n- **Provider Search Filter** - Quick filter to find providers by name (#435)\n- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436)\n- **Deeplink Usage Config** - Import usage query config via deeplink (#400)\n- **Provider Icon Colors** - Customize provider icon colors (#385)\n- **Skills Multi-App Support** - Skills now support both Claude Code and Codex (#365)\n- **Closable Toasts** - Close button for switch toast and all success toasts (#350)\n- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog\n- **MCP Import** - Import MCP servers from installed apps\n- **Common Config Snippet Extraction** - Extract reusable common config snippets from the current provider or editor content (Claude/Codex/Gemini)\n- **Usage Enhancements** - Model extraction, request logging improvements, cache hit/creation metrics, and auto-refresh (#455, #508)\n- **Error Request Logging** - Detailed logging for proxy requests (#401)\n- **Linux Packaging** - Added RPM and Flatpak packaging targets\n- **Provider Presets & Icons** - Added/updated partner presets and icons (e.g., MiMo, DMXAPI, Cubence)\n\n### Changed\n\n- **Usage Terminology** - Rename \"Cache Read/Write\" to \"Cache Hit/Creation\" across all languages (#508)\n- **Model Pricing Data** - Refresh built-in model pricing table (Claude full version IDs, GPT-5 series, Gemini ID formats, and Chinese models) (#508)\n- **Proxy Header Forwarding** - Switch to a blacklist approach and improve header passthrough compatibility (#508)\n- **Failover Behavior** - Bypass timeout/retry configs when failover is disabled; update default failover timeout and circuit breaker values (#508, #521)\n- **Provider Presets** - Update default model versions and change the default Qwen base URL (#517)\n- **Skills Management** - Unify Skills management architecture with SSOT + React Query; improve caching for discoverable skills\n- **Settings UX** - Reorder items in the Advanced tab for better discoverability\n- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active\n\n### Fixed\n\n- **Security** - Security fixes for JavaScript executor and usage script (#151)\n- **Usage Timezone & Parsing** - Fix datetime picker timezone handling; improve token parsing/billing for Gemini and Codex formats (#508)\n- **Windows Compatibility** - Improve MCP export and version check behavior to avoid terminal popups\n- **Windows Startup** - Use system titlebar to prevent black screen on startup\n- **WebView Compatibility** - Add fallback for crypto.randomUUID() on older WebViews\n- **macOS Autostart** - Use `.app` bundle path to prevent terminal window popups\n- **Database** - Add missing schema migrations; show an error dialog on initialization failure with a retry option\n- **Import/Export** - Restrict SQL import to CC Switch exported backups only; refresh providers immediately after import\n- **Prompts** - Allow saving prompts with empty content\n- **MCP Sync** - Skip sync when the target CLI app is not installed\n- **Common Config (Codex)** - Preserve MCP server `base_url` during extraction and remove provider-specific `model_providers` blocks\n- **Proxy** - Improve takeover detection and stability; clean up model override env vars when switching providers in takeover mode (#508)\n- **Skills** - Skip hidden directories during discovery; fix wrong skill repo branch\n- **Settings Navigation** - Navigate to About tab when clicking update badge\n- **UI** - Fix dialogs not opening on first click and improve window dragging area in `FullScreenPanel`\n\n---\n\n## [3.9.0-3] - 2025-12-29\n\n### Beta Release\n\nThird beta release with important bug fixes for Windows compatibility, UI improvements, and new features.\n\n### Added\n\n- **Universal Provider** - Support for universal provider configurations (#348)\n- **Provider Search Filter** - Quick filter to find providers by name (#435)\n- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436)\n- **Xiaomi MiMo Icon** - Added MiMo icon and Claude provider configuration (#470)\n- **Usage Model Extraction** - Extract model info from usage statistics (#455)\n- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog\n- **Exit Animations** - Added exit animation to FullScreenPanel dialogs\n- **Fade Transitions** - Smooth fade transitions for app/view/panel switching\n\n### Fixed\n\n#### Windows\n- Wrap npx/npm commands with `cmd /c` for MCP export\n- Prevent terminal windows from appearing during version check\n\n#### macOS\n- Use .app bundle path for autostart to prevent terminal window popup\n\n#### UI\n- Resolve Dialog/Modal not opening on first click (#492)\n- Improve dark mode text contrast for form labels\n- Reduce header spacing and fix layout shift on view switch\n- Prevent header layout shift when switching views\n\n#### Database & Schema\n- Add missing base columns migration for proxy_config\n- Add backward compatibility check for proxy_config seed insert\n\n#### Other\n- Use local timezone and robust DST handling in usage stats (#500)\n- Remove deprecated `sync_enabled_to_codex` call\n- Gracefully handle invalid Codex config.toml during MCP sync\n- Add missing translations for reasoning model and OpenRouter compat mode\n\n### Improved\n\n- **macOS Tray** - Use macOS tray template icon\n- **Header Alignment** - Remove macOS titlebar tint, align custom header\n- **Shadow Removal** - Cleaner UI by removing shadow styles\n- **Code Inspector** - Added code-inspector-plugin for development\n- **i18n** - Complete internationalization for usage panel and settings\n- **Sponsor Logos** - Made sponsor logos clickable\n\n### Stats\n\n- 35 commits since v3.9.0-2\n- 5 files changed in test/lint fixes\n\n---\n\n## [3.9.0-2] - 2025-12-20\n\n### Beta Release\n\nSecond beta release focusing on proxy stability, import safety, and provider preset polish.\n\n### Added\n\n- **DMXAPI Partner** - Added DMXAPI as an official partner provider preset\n- **Provider Icons** - Added provider icons for OpenRouter, LongCat, ModelScope, and AiHubMix\n\n### Changed\n\n- **Proxy (OpenRouter)** - Switched OpenRouter to passthrough mode for native Claude API\n\n### Fixed\n\n- **Import/Export** - Restrict SQL import to CC Switch exported backups only; refresh providers immediately after import\n- **Proxy** - Respect existing Claude token when syncing; add fallback recovery for orphaned takeover state; remove global auto-start flag\n- **Windows** - Add minimum window size to Windows platform config\n- **UI** - Improve About section UI (#419) and unify header toolbar styling\n\n### Stats\n\n- 13 commits since v3.9.0-1\n\n---\n\n## [3.9.0-1] - 2025-12-18\n\n### Beta Release\n\nThis beta release introduces the **Local API Proxy** feature, along with Skills multi-app support, UI improvements, and numerous bug fixes.\n\n### Major Features\n\n#### Local Proxy Server\n- **Local HTTP Proxy** - High-performance proxy server built on Axum framework\n- **Multi-app Support** - Unified proxy for Claude Code, Codex, and Gemini CLI API requests\n- **Per-app Takeover** - Independent control over which apps route through the proxy\n- **Live Config Takeover** - Automatically backs up and redirects CLI configurations to local proxy\n\n#### Auto Failover\n- **Circuit Breaker** - Automatically detects provider failures and triggers protection\n- **Smart Failover** - Automatically switches to backup provider when current one is unavailable\n- **Health Tracking** - Real-time monitoring of provider availability\n- **Independent Failover Queues** - Each app maintains its own failover queue\n\n#### Monitoring\n- **Request Logging** - Detailed logging of all proxy requests\n- **Usage Statistics** - Token consumption, latency, success rate metrics\n- **Real-time Status** - Frontend displays proxy status and statistics\n\n#### Skills Multi-App Support\n- **Multi-app Support** - Skills now support both Claude and Codex (#365)\n- **Multi-app Migration** - Existing Skills auto-migrate to multi-app structure (#378)\n- **Installation Path Fix** - Use directory basename for skill installation path (#358)\n\n### Added\n- **Provider Icon Colors** - Customize provider icon colors (#385)\n- **Deeplink Usage Config** - Import usage query config via deeplink (#400)\n- **Error Request Logging** - Detailed logging for proxy requests (#401)\n- **Closable Toast** - Added close button to switch notification toast (#350)\n- **Icon Color Component** - ProviderIcon component supports color prop (#384)\n\n### Fixed\n\n#### Proxy Related\n- Takeover Codex base_url via model_provider\n- Harden crash recovery with fallback detection\n- Sync UI when active provider differs from current setting\n- Resolve circuit breaker race condition and error classification\n- Stabilize live takeover and provider editing\n- Reset health badges when proxy stops\n- Retry failover for all HTTP errors including 4xx\n- Fix HalfOpen counter underflow and config field inconsistencies\n- Resolve circuit breaker state persistence and HalfOpen deadlock\n- Auto-recover live config after abnormal exit\n- Update live backup when hot-switching provider in proxy mode\n- Wait for server shutdown before exiting app\n- Disable auto-start on app launch by resetting enabled flag on stop\n- Sync live config tokens to database before takeover\n- Resolve 404 error and auto-setup proxy targets\n\n#### MCP Related\n- Skip sync when target CLI app is not installed\n- Improve upsert and import robustness\n- Use browser-compatible platform detection for MCP presets\n\n#### UI Related\n- Restore fade transition for Skills button\n- Add close button to all success toasts\n- Prevent card jitter when health badge appears\n- Update SettingsPage tab styles (#342)\n\n#### Other\n- Fix Azure website link (#407)\n- Add fallback to provider config for usage credentials (#360)\n- Fix Windows black screen on startup (use system titlebar)\n- Add fallback for crypto.randomUUID() on older WebViews\n- Use correct npm package for Codex CLI version check\n- Security fixes for JavaScript executor and usage script (#151)\n\n### Improved\n- **Proxy Active Theme** - Apply emerald theme when proxy takeover is active\n- **Card Animation** - Improved provider card hover animation\n- **Remove Restart Prompt** - No longer prompts restart when switching providers\n\n### Technical\n- Implement per-app takeover mode\n- Proxy module contains 20+ Rust files with complete layered architecture\n- Add 5 new database tables for proxy functionality\n- Modularize handlers.rs to reduce code duplication\n- Remove is_proxy_target in favor of failover_queue\n\n### Stats\n- 55 commits since v3.8.2\n- 164 files changed\n- +22,164 / -570 lines\n\n---\n\n## [3.8.0] - 2025-11-28\n\n### Major Updates\n\n- **Persistence architecture upgrade** - Moved from single JSON storage to SQLite + JSON dual-layer; added schema versioning, transactions, and SQL import/export; first launch auto-migrates `config.json` to SQLite while keeping originals safe.\n- **Brand new UI** - Full layout redesign, unified component/ConfirmDialog styles, smoother animations, overscroll disabled; Tailwind CSS downgraded to v3.4 for compatibility.\n- **Japanese language support** - UI now localized in Chinese/English/Japanese.\n\n### Added\n\n- **Skills recursive scanning** - Discovers nested `SKILL.md` files across multi-level directories; same-name skills allowed by full-path dedup.\n- **Provider icons** - Presets ship with default icons; custom icon colors; icons retained when duplicating providers.\n- **Auto launch on startup** - One-click enable/disable using Registry/LaunchAgent/XDG autostart.\n- **Provider preset** - Added MiniMax partner preset.\n- **Form validation** - Required fields get real-time validation and unified toast messaging.\n\n### Fixed\n\n- **Custom endpoints loss** - Switched provider updates to `UPDATE` to avoid cascade deletes from `INSERT OR REPLACE`.\n- **Gemini config writing** - Correctly writes custom env vars to `.env` and keeps auth configs isolated.\n- **Provider validation** - Handles missing current provider IDs and preserves icon fields on duplicate.\n- **Linux rendering** - Fixed WebKitGTK DMA-BUF rendering and preserved user `.desktop` customizations.\n- **Misc** - Removed redundant usage queries; corrected DMXAPI auth token field; restored missing deeplink translations; fixed usage script template init.\n\n### Technical\n\n- **Database modules** - Added `schema`, `backup`, `migration`, and DAO layers for providers/MCP/prompts/skills/settings.\n- **Service modularization** - Split provider service into live/auth/endpoints/usage modules; deeplink parsing/import logic modularized.\n- **Code cleanup** - Removed legacy JSON-era import/export, unused MCP types; unified error handling; tests migrated to SQLite backend and MSW handlers updated.\n\n### Migration Notes\n\n- First launch auto-migrates data from `config.json` to SQLite and device settings to `settings.json`; originals kept; error dialog on failure; dry-run supported.\n\n### Stats\n\n- 51 commits since v3.7.1; 207 files changed; +17,297 / -6,870 lines. See [release-note-v3.8.0](docs/release-notes/v3.8.0-en.md) for details.\n\n---\n\n## [3.7.1] - 2025-11-22\n\n### Fixed\n\n- **Skills third-party repository installation** (#268) - Fixed installation failure for skills repositories with custom subdirectories (e.g., `ComposioHQ/awesome-claude-skills`)\n- **Gemini configuration persistence** - Resolved issue where settings.json edits were lost when switching providers\n- **Dialog overlay click protection** - Prevented dialogs from closing when clicking outside, avoiding accidental form data loss (affects 11 dialog components)\n\n### Added\n\n- **Gemini configuration directory support** (#255) - Added custom configuration directory option for Gemini in settings\n- **ArchLinux installation support** (#259) - Added AUR installation via `paru -S cc-switch-bin`\n\n### Improved\n\n- **Skills error messages i18n** - Added 28+ detailed error messages (English & Chinese) with specific resolution suggestions\n- **Download timeout** - Extended from 15s to 60s to reduce network-related false positives\n- **Code formatting** - Applied unified Rust (`cargo fmt`) and TypeScript (`prettier`) formatting standards\n\n### Reverted\n\n- **Auto-launch on system startup** - Temporarily reverted feature pending further testing and optimization\n\n---\n\n## [3.7.0] - 2025-11-19\n\n### Major Features\n\n#### Gemini CLI Integration\n\n- **Complete Gemini CLI support** - Third major application added alongside Claude Code and Codex\n- **Dual-file configuration** - Support for both `.env` and `settings.json` file formats\n- **Environment variable detection** - Auto-detect `GOOGLE_GEMINI_BASE_URL`, `GEMINI_MODEL`, etc.\n- **MCP management** - Full MCP configuration capabilities for Gemini\n- **Provider presets**\n - Google Official (OAuth authentication)\n - PackyCode (partner integration)\n - Custom endpoint support\n- **Deep link support** - Import Gemini providers via `ccswitch://` protocol\n- **System tray integration** - Quick-switch Gemini providers from tray menu\n- **Backend modules** - New `gemini_config.rs` (20KB) and `gemini_mcp.rs`\n\n#### MCP v3.7.0 Unified Architecture\n\n- **Unified management panel** - Single interface for Claude/Codex/Gemini MCP servers\n- **SSE transport type** - New Server-Sent Events support alongside stdio/http\n- **Smart JSON parser** - Fault-tolerant parsing of various MCP config formats\n- **Extended field support** - Preserve custom fields in Codex TOML conversion\n- **Codex format correction** - Proper `[mcp_servers]` format (auto-cleanup of incorrect `[mcp.servers]`)\n- **Import/export system** - Unified import from Claude/Codex/Gemini live configs\n- **UX improvements**\n - Default app selection in forms\n - JSON formatter for config validation\n - Improved layout and visual hierarchy\n - Better validation error messages\n\n#### Claude Skills Management System\n\n- **GitHub repository integration** - Auto-scan and discover skills from GitHub repos\n- **Pre-configured repositories**\n - `ComposioHQ/awesome-claude-skills` (curated collection)\n - `anthropics/skills` (official Anthropic skills)\n - `cexll/myclaude` (community, with subdirectory scanning)\n- **Lifecycle management**\n - One-click install to `~/.claude/skills/`\n - Safe uninstall with state tracking\n - Update checking (infrastructure ready)\n- **Custom repository support** - Add any GitHub repo as a skill source\n- **Subdirectory scanning** - Optional `skillsPath` for repos with nested skill directories\n- **Backend architecture** - `SkillService` (526 lines) with GitHub API integration\n- **Frontend interface**\n - SkillsPage: Browse and manage skills\n - SkillCard: Visual skill presentation\n - RepoManager: Repository management dialog\n- **State persistence** - Installation state stored in `skills.json`\n- **Full i18n support** - Complete Chinese/English translations (47+ keys)\n\n#### Prompts (System Prompts) Management\n\n- **Multi-preset management** - Create, edit, and switch between multiple system prompts\n- **Cross-app support**\n - Claude: `~/.claude/CLAUDE.md`\n - Codex: `~/.codex/AGENTS.md`\n - Gemini: `~/.gemini/GEMINI.md`\n- **Markdown editor** - Full-featured CodeMirror 6 editor with syntax highlighting\n- **Smart synchronization**\n - Auto-write to live files on enable\n - Content backfill protection (save current before switching)\n - First-launch auto-import from live files\n- **Single-active enforcement** - Only one prompt can be active at a time\n- **Delete protection** - Cannot delete active prompts\n- **Backend service** - `PromptService` (213 lines) with CRUD operations\n- **Frontend components**\n - PromptPanel: Main management interface (177 lines)\n - PromptFormModal: Edit dialog with validation (160 lines)\n - MarkdownEditor: CodeMirror integration (159 lines)\n - usePromptActions: Business logic hook (152 lines)\n- **Full i18n support** - Complete Chinese/English translations (41+ keys)\n\n#### Deep Link Protocol (ccswitch://)\n\n- **Protocol registration** - `ccswitch://` URL scheme for one-click imports\n- **Provider import** - Import provider configurations from URLs or shared links\n- **Lifecycle integration** - Deep link handling integrated into app startup\n- **Cross-platform support** - Works on Windows, macOS, and Linux\n\n#### Environment Variable Conflict Detection\n\n- **Claude & Codex detection** - Identify conflicting environment variables\n- **Gemini auto-detection** - Automatic environment variable discovery\n- **Conflict management** - UI for resolving configuration conflicts\n- **Prevention system** - Warn before overwriting existing configurations\n\n### New Features\n\n#### Provider Management\n\n- **DouBaoSeed preset** - Added ByteDance's DouBao provider\n- **Kimi For Coding** - Moonshot AI coding assistant\n- **BaiLing preset** - BaiLing AI integration\n- **Removed AnyRouter preset** - Discontinued provider\n- **Model configuration** - Support for custom model names in Codex and Gemini\n- **Provider notes field** - Add custom notes to providers for better organization\n\n#### Configuration Management\n\n- **Common config migration** - Moved Claude common config snippets from localStorage to `config.json`\n- **Unified persistence** - Common config snippets now shared across all apps\n- **Auto-import on first launch** - Automatically import configs from live files on first run\n- **Backfill priority fix** - Correct priority handling when enabling prompts\n\n#### UI/UX Improvements\n\n- **macOS native design** - Migrated color scheme to macOS native design system\n- **Window centering** - Default window position centered on screen\n- **Password input fixes** - Disabled Edge/IE reveal and clear buttons\n- **URL overflow prevention** - Fixed overflow in provider cards\n- **Error notification enhancement** - Copy-to-clipboard for error messages\n- **Tray menu sync** - Real-time sync after drag-and-drop sorting\n\n### Improvements\n\n#### Architecture\n\n- **MCP v3.7.0 cleanup** - Removed legacy code and warnings\n- **Unified structure** - Default initialization with v3.7.0 unified structure\n- **Backward compatibility** - Compilation fixes for older configs\n- **Code formatting** - Applied consistent formatting across backend and frontend\n\n#### Platform Compatibility\n\n- **Windows fix** - Resolved winreg API compatibility issue (v0.52)\n- **Safe pattern matching** - Replaced `unwrap()` with safe patterns in tray menu\n\n#### Configuration\n\n- **MCP sync on switch** - Sync MCP configs for all apps when switching providers\n- **Gemini form sync** - Fixed form fields syncing with environment editor\n- **Gemini config reading** - Read from both `.env` and `settings.json`\n- **Validation improvements** - Enhanced input validation and boundary checks\n\n#### Internationalization\n\n- **JSON syntax fixes** - Resolved syntax errors in locale files\n- **App name i18n** - Added internationalization support for app names\n- **Deduplicated labels** - Reused providerForm keys to reduce duplication\n- **Gemini MCP title** - Added missing Gemini MCP panel title\n\n### Bug Fixes\n\n#### Critical Fixes\n\n- **Usage script validation** - Added input validation and boundary checks\n- **Gemini validation** - Relaxed validation when adding providers\n- **TOML quote normalization** - Handle CJK quotes to prevent parsing errors\n- **MCP field preservation** - Preserve custom fields in Codex TOML editor\n- **Password input** - Fixed white screen crash (FormLabel → Label)\n\n#### Stability\n\n- **Tray menu safety** - Replaced unwrap with safe pattern matching\n- **Error isolation** - Tray menu update failures don't block main operations\n- **Import classification** - Set category to custom for imported default configs\n\n#### UI Fixes\n\n- **Model placeholders** - Removed misleading model input placeholders\n- **Base URL population** - Auto-fill base URL for non-official providers\n- **Drag sort sync** - Fixed tray menu order after drag-and-drop\n\n### Technical Improvements\n\n#### Code Quality\n\n- **Type safety** - Complete TypeScript type coverage across codebase\n- **Test improvements** - Simplified boolean assertions in tests\n- **Clippy warnings** - Fixed `uninlined_format_args` warnings\n- **Code refactoring** - Extracted templates, optimized logic flows\n\n#### Dependencies\n\n- **Tauri** - Updated to 2.8.x series\n- **Rust dependencies** - Added `anyhow`, `zip`, `serde_yaml`, `tempfile` for Skills\n- **Frontend dependencies** - Added CodeMirror 6 packages for Markdown editor\n- **winreg** - Updated to v0.52 (Windows compatibility)\n\n#### Performance\n\n- **Startup optimization** - Removed legacy migration scanning\n- **Lock management** - Improved RwLock usage to prevent deadlocks\n- **Background query** - Enabled background mode for usage polling\n\n### Statistics\n\n- **Total commits**: 85 commits from v3.6.0 to v3.7.0\n- **Code changes**: 152 files changed, 18,104 insertions(+), 3,732 deletions(-)\n- **New modules**:\n - Skills: 2,034 lines (21 files)\n - Prompts: 1,302 lines (20 files)\n - Gemini: ~1,000 lines (multiple files)\n - MCP refactor: ~3,000 lines (refactored)\n\n### Strategic Positioning\n\nv3.7.0 represents a major evolution from \"Provider Switcher\" to **\"All-in-One AI CLI Management Platform\"**:\n\n1. **Capability Extension** - Skills provide external ability integration\n2. **Behavior Customization** - Prompts enable AI personality presets\n3. **Configuration Unification** - MCP v3.7.0 eliminates app silos\n4. **Ecosystem Openness** - Deep links enable community sharing\n5. **Multi-AI Support** - Claude/Codex/Gemini trinity\n6. **Intelligent Detection** - Auto-discovery of environment conflicts\n\n### Notes\n\n- Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x for one-time migration\n- Skills and Prompts management are new features requiring no migration\n- Gemini CLI support requires Gemini CLI to be installed separately\n- MCP v3.7.0 unified structure is backward compatible with previous configs\n\n## [3.6.0] - 2025-11-07\n\n### ✨ New Features\n\n- **Provider Duplicate** - Quick duplicate existing provider configurations for easy variant creation\n- **Edit Mode Toggle** - Show/hide drag handles to optimize editing experience\n- **Custom Endpoint Management** - Support multi-endpoint configuration for aggregator providers\n- **Usage Query Enhancements**\n - Auto-refresh interval: Support periodic automatic usage query\n - Test Script API: Validate JavaScript scripts before execution\n - Template system expansion: Custom blank template, support for access token and user ID parameters\n- **Configuration Editor Improvements**\n - Add JSON format button\n - Real-time TOML syntax validation for Codex configuration\n- **Auto-sync on Directory Change** - When switching Claude/Codex config directories (e.g., WSL environment), automatically sync current provider to new directory without manual operation\n- **Load Live Config When Editing Active Provider** - When editing the currently active provider, prioritize displaying the actual effective configuration to protect user manual modifications\n- **New Provider Presets** - DMXAPI, Azure Codex, AnyRouter, AiHubMix, MiniMax\n- **Partner Promotion Mechanism** - Support ecosystem partner promotion (e.g., Zhipu GLM Z.ai)\n\n### 🔧 Improvements\n\n- **Configuration Directory Switching**\n - Introduced unified post-change sync utility (`postChangeSync.ts`)\n - Auto-sync current providers to new directory when changing Claude/Codex config directories\n - Perfect support for WSL environment switching\n - Auto-sync after config import to ensure immediate effectiveness\n - Use Result pattern for graceful error handling without blocking main flow\n - Distinguish \"fully successful\" and \"partially successful\" states for precise user feedback\n- **UI/UX Enhancements**\n - Provider cards: Unique icons and color identification\n - Unified border design system across all components\n - Drag interaction optimization: Push effect animation, improved handle icons\n - Enhanced current provider visual feedback\n - Dialog size standardization and layout consistency\n - Form experience: Optimized model placeholders, simplified provider hints, category-specific hints\n- **Complete Internationalization Coverage**\n - Error messages internationalization\n - Tray menu internationalization\n - All UI components internationalization\n- **Usage Display Moved Inline** - Usage display moved next to enable button\n\n### 🐛 Bug Fixes\n\n- **Configuration Sync**\n - Fixed `apiKeyUrl` priority issue\n - Fixed MCP sync-to-other-side functionality failure\n - Fixed sync issues after config import\n - Prevent silent fallback and data loss on config error\n- **Usage Query**\n - Fixed auto-query interval timing issue\n - Ensure refresh button shows loading animation on click\n- **UI Issues**\n - Fixed name collision error (`get_init_error` command)\n - Fixed language setting rollback after successful save\n - Fixed language switch state reset (dependency cycle)\n - Fixed edit mode button alignment\n- **Configuration Management**\n - Fixed Codex API Key auto-sync\n - Fixed endpoint speed test functionality\n - Fixed provider duplicate insertion position (next to original provider)\n - Fixed custom endpoint preservation in edit mode\n- **Startup Issues**\n - Force exit on config error (no silent fallback)\n - Eliminate code duplication causing initialization errors\n\n### 🏗️ Technical Improvements (For Developers)\n\n**Backend Refactoring (Rust)** - Completed 5-phase refactoring:\n\n- **Phase 1**: Unified error handling (`AppError` + i18n error messages)\n- **Phase 2**: Command layer split by domain (`commands/{provider,mcp,config,settings,plugin,misc}.rs`)\n- **Phase 3**: Integration tests and transaction mechanism (config snapshot + failure rollback)\n- **Phase 4**: Extracted Service layer (`services/{provider,mcp,config,speedtest}.rs`)\n- **Phase 5**: Concurrency optimization (`RwLock` instead of `Mutex`, scoped guard to avoid deadlock)\n\n**Frontend Refactoring (React + TypeScript)** - Completed 4-stage refactoring:\n\n- **Stage 1**: Test infrastructure (vitest + MSW + @testing-library/react)\n- **Stage 2**: Extracted custom hooks (`useProviderActions`, `useMcpActions`, `useSettings`, `useImportExport`, etc.)\n- **Stage 3**: Component splitting and business logic extraction\n- **Stage 4**: Code cleanup and formatting unification\n\n**Testing System**:\n\n- Hooks unit tests 100% coverage\n- Integration tests covering key processes (App, SettingsDialog, MCP Panel)\n- MSW mocking backend API to ensure test independence\n\n**Code Quality**:\n\n- Unified parameter format: All Tauri commands migrated to camelCase (Tauri 2 specification)\n- `AppType` renamed to `AppId`: Semantically clearer\n- Unified parsing with `FromStr` trait: Centralized `app` parameter parsing\n- Eliminate code duplication: DRY violations cleanup\n- Remove unused code: `missing_param` helper function, deprecated `tauri-api.ts`, redundant `KimiModelSelector` component\n\n**Internal Optimizations**:\n\n- **Removed Legacy Migration Logic**: v3.6 removed v1 config auto-migration and copy file scanning logic\n - ✅ **Impact**: Improved startup performance, cleaner code\n - ✅ **Compatibility**: v2 format configs fully compatible, no action required\n - ⚠️ **Note**: Users upgrading from v3.1.0 or earlier should first upgrade to v3.2.x or v3.5.x for one-time migration, then upgrade to v3.6\n- **Command Parameter Standardization**: Backend unified to use `app` parameter (values: `claude` or `codex`)\n - ✅ **Impact**: More standardized code, friendlier error prompts\n - ✅ **Compatibility**: Frontend fully adapted, users don't need to care about this change\n\n### 📦 Dependencies\n\n- Updated to Tauri 2.8.x\n- Updated to TailwindCSS 4.x\n- Updated to TanStack Query v5.90.x\n- Maintained React 18.2.x and TypeScript 5.3.x\n\n## [3.5.0] - 2025-01-15\n\n### ⚠ Breaking Changes\n\n- Tauri commands only accept the `app` parameter (`claude`/`codex`); removed `app_type`/`appType` compatibility.\n- Frontend types are standardized to `AppId` (removed `AppType` export); variable naming is standardized to `appId`.\n\n### ✨ New Features\n\n- **MCP (Model Context Protocol) Management** - Complete MCP server configuration management system\n - Add, edit, delete, and toggle MCP servers in `~/.claude.json`\n - Support for stdio and http server types with command validation\n - Built-in templates for popular MCP servers (mcp-fetch, etc.)\n - Real-time enable/disable toggle for MCP servers\n - Atomic file writing to prevent configuration corruption\n- **Configuration Import/Export** - Backup and restore your provider configurations\n - Export all configurations to JSON file with one click\n - Import configurations with validation and automatic backup\n - Automatic backup rotation (keeps 10 most recent backups)\n - Progress modal with detailed status feedback\n- **Endpoint Speed Testing** - Test API endpoint response times\n - Measure latency to different provider endpoints\n - Visual indicators for connection quality\n - Help users choose the fastest provider\n\n### 🔧 Improvements\n\n- Complete internationalization (i18n) coverage for all UI components\n- Enhanced error handling and user feedback throughout the application\n- Improved configuration file management with better validation\n- Added new provider presets: Longcat, kat-coder\n- Updated GLM provider configurations with latest models\n- Refined UI/UX with better spacing, icons, and visual feedback\n- Enhanced tray menu functionality and responsiveness\n- **Standardized release artifact naming** - All platform releases now use consistent version-tagged filenames:\n - macOS: `CC-Switch-v{version}-macOS.tar.gz` / `.zip`\n - Windows: `CC-Switch-v{version}-Windows.msi` / `-Portable.zip`\n - Linux: `CC-Switch-v{version}-Linux.AppImage` / `.deb`\n\n### 🐛 Bug Fixes\n\n- Fixed layout shifts during provider switching\n- Improved config file path handling across different platforms\n- Better error messages for configuration validation failures\n- Fixed various edge cases in configuration import/export\n\n### 📦 Technical Details\n\n- Enhanced `import_export.rs` module with backup management\n- New `claude_mcp.rs` module for MCP configuration handling\n- Improved state management and lock handling in Rust backend\n- Better TypeScript type safety across the codebase\n\n## [3.4.0] - 2025-10-01\n\n### ✨ Features\n\n- Enable internationalization via i18next with a Chinese default and English fallback, plus an in-app language switcher\n- Add Claude plugin sync while retiring the legacy VS Code integration controls (Codex no longer requires settings.json edits)\n- Extend provider presets with optional API key URLs and updated models, including DeepSeek-V3.1-Terminus and Qwen3-Max\n- Support portable mode launches and enforce a single running instance to avoid conflicts\n\n### 🔧 Improvements\n\n- Allow minimizing the window to the system tray and add macOS Dock visibility management for tray workflows\n- Refresh the Settings modal with a scrollable layout, save icon, and cleaner language section\n- Smooth provider toggle states with consistent button widths/icons and prevent layout shifts when switching between Claude and Codex\n- Adjust the Windows MSI installer to target per-user LocalAppData and improve component tracking reliability\n\n### 🐛 Fixes\n\n- Remove the unnecessary OpenAI auth requirement from third-party provider configurations\n- Fix layout shifts while switching app types with Claude plugin sync enabled\n- Align Enable/In Use button states to avoid visual jank across app views\n\n## [3.3.0] - 2025-09-22\n\n### ✨ Features\n\n- Add “Apply to VS Code / Remove from VS Code” actions on provider cards, writing settings for Code/Insiders/VSCodium variants _(Removed in 3.4.x)_\n- Enable VS Code auto-sync by default with window broadcast and tray hooks so Codex switches sync silently _(Removed in 3.4.x)_\n- Extend the Codex provider wizard with display name, dedicated API key URL, and clearer guidance\n- Introduce shared common config snippets with JSON/TOML reuse, validation, and consistent error surfaces\n\n### 🔧 Improvements\n\n- Keep the tray menu responsive when the window is hidden and standardize button styling and copy\n- Disable modal backdrop blur on Linux (WebKitGTK/Wayland) to avoid freezes; restore the window when clicking the macOS Dock icon\n- Support overriding config directories on WSL, refine placeholders/descriptions, and fix VS Code button wrapping on Windows\n- Add a `created_at` timestamp to provider records for future sorting and analytics\n\n### 🐛 Fixes\n\n- Correct regex escapes and common snippet trimming in the Codex wizard to prevent validation issues\n- Harden the VS Code sync flow with more reliable TOML/JSON parsing while reducing layout jank\n- Bundle `@codemirror/lint` to reinstate live linting in config editors\n\n## [3.2.0] - 2025-09-13\n\n### ✨ New Features\n\n- System tray provider switching with dynamic menu for Claude/Codex\n- Frontend receives `provider-switched` events and refreshes active app\n- Built-in update flow via Tauri Updater plugin with dismissible UpdateBadge\n\n### 🔧 Improvements\n\n- Single source of truth for provider configs; no duplicate copy files\n- One-time migration imports existing copies into `config.json` and archives originals\n- Duplicate provider de-duplication by name + API key at startup\n- Atomic writes for Codex `auth.json` + `config.toml` with rollback on failure\n- Logging standardized (Rust): use `log::{info,warn,error}` instead of stdout prints\n- Tailwind v4 integration and refined dark mode handling\n\n### 🐛 Fixes\n\n- Remove/minimize debug console logs in production builds\n- Fix CSS minifier warnings for scrollbar pseudo-elements\n- Prettier formatting across codebase for consistent style\n\n### 📦 Dependencies\n\n- Tauri: 2.8.x (core, updater, process, opener, log plugins)\n- React: 18.2.x · TypeScript: 5.3.x · Vite: 5.x\n\n### 🔄 Notes\n\n- `connect-src` CSP remains permissive for compatibility; can be tightened later as needed\n\n## [3.1.1] - 2025-09-03\n\n### 🐛 Bug Fixes\n\n- Fixed the default codex config.toml to match the latest modifications\n- Improved provider configuration UX with custom option\n\n### 📝 Documentation\n\n- Updated README with latest information\n\n## [3.1.0] - 2025-09-01\n\n### ✨ New Features\n\n- **Added Codex application support** - Now supports both Claude Code and Codex configuration management\n - Manage auth.json and config.toml for Codex\n - Support for backup and restore operations\n - Preset providers for Codex (Official, PackyCode)\n - API Key auto-write to auth.json when using presets\n- **New UI components**\n - App switcher with segmented control design\n - Dual editor form for Codex configuration\n - Pills-style app switcher with consistent button widths\n- **Enhanced configuration management**\n - Multi-app config v2 structure (claude/codex)\n - Automatic v1→v2 migration with backup\n - OPENAI_API_KEY validation for non-official presets\n - TOML syntax validation for config.toml\n\n### 🔧 Technical Improvements\n\n- Unified Tauri command API with app_type parameter\n- Backward compatibility for app/appType parameters\n- Added get_config_status/open_config_folder/open_external commands\n- Improved error handling for empty config.toml\n\n### 🐛 Bug Fixes\n\n- Fixed config path reporting and folder opening for Codex\n- Corrected default import behavior when main config is missing\n- Fixed non_snake_case warnings in commands.rs\n\n## [3.0.0] - 2025-08-27\n\n### 🚀 Major Changes\n\n- **Complete migration from Electron to Tauri 2.0** - The application has been completely rewritten using Tauri, resulting in:\n - **90% reduction in bundle size** (from ~150MB to ~15MB)\n - **Significantly improved startup performance**\n - **Native system integration** without Chromium overhead\n - **Enhanced security** with Rust backend\n\n### ✨ New Features\n\n- **Native window controls** with transparent title bar on macOS\n- **Improved file system operations** using Rust for better performance\n- **Enhanced security model** with explicit permission declarations\n- **Better platform detection** using Tauri's native APIs\n\n### 🔧 Technical Improvements\n\n- Migrated from Electron IPC to Tauri command system\n- Replaced Node.js file operations with Rust implementations\n- Implemented proper CSP (Content Security Policy) for enhanced security\n- Added TypeScript strict mode for better type safety\n- Integrated Rust cargo fmt and clippy for code quality\n\n### 🐛 Bug Fixes\n\n- Fixed bundle identifier conflict on macOS (changed from .app to .desktop)\n- Resolved platform detection issues\n- Improved error handling in configuration management\n\n### 📦 Dependencies\n\n- **Tauri**: 2.8.2\n- **React**: 18.2.0\n- **TypeScript**: 5.3.0\n- **Vite**: 5.0.0\n\n### 🔄 Migration Notes\n\nFor users upgrading from v2.x (Electron version):\n\n- Configuration files remain compatible - no action required\n- The app will automatically migrate your existing provider configurations\n- Window position and size preferences have been reset to defaults\n\n#### Backup on v1→v2 Migration (cc-switch internal config)\n\n- When the app detects an old v1 config structure at `~/.cc-switch/config.json`, it now creates a timestamped backup before writing the new v2 structure.\n- Backup location: `~/.cc-switch/config.v1.backup.<timestamp>.json`\n- This only concerns cc-switch's own metadata file; your actual provider files under `~/.claude/` and `~/.codex/` are untouched.\n\n### 🛠️ Development\n\n- Added `pnpm typecheck` command for TypeScript validation\n- Added `pnpm format` and `pnpm format:check` for code formatting\n- Rust code now uses cargo fmt for consistent formatting\n\n## [2.0.0] - Previous Electron Release\n\n### Features\n\n- Multi-provider configuration management\n- Quick provider switching\n- Import/export configurations\n- Preset provider templates\n\n---\n\n## [1.0.0] - Initial Release\n\n### Features\n\n- Basic provider management\n- Claude Code integration\n- Configuration file handling\n"}}, "docs/user-manual/ja/2-providers/2.1-add.md": {"bab9119da3c9dbecaeca84f2a022c9b7ad8e52fe": {"t": 1785596044.8885024, "n": 20711, "h": "dcd00db657a4d6ef", "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 | 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"}}, "README_JA.md": {"bab9119da3c9dbecaeca84f2a022c9b7ad8e52fe": {"t": 1785596141.0531394, "n": 25081, "h": "d81a76f171c4bba9", "c": "<div align=\"center\">\n\n# CC Switch\n\n### Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes Agent のオールインワン管理ツール\n\n[![Version](https://img.shields.io/github/v/release/farion1231/cc-switch?color=blue&label=version)](https://github.com/farion1231/cc-switch/releases)\n[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)](https://github.com/farion1231/cc-switch/releases)\n[![Built with Tauri](https://img.shields.io/badge/built%20with-Tauri%202-orange.svg)](https://tauri.app/)\n[![Downloads](https://img.shields.io/github/downloads/farion1231/cc-switch/total)](https://github.com/farion1231/cc-switch/releases/latest)\n\n<a href=\"https://trendshift.io/repositories/15372\" target=\"_blank\"><img src=\"https://trendshift.io/api/badge/repositories/15372\" alt=\"farion1231%2Fcc-switch | Trendshift\" style=\"width: 250px; height: 55px;\" width=\"250\" height=\"55\"/></a>\n\n### 🌐 唯一の公式サイト:**[ccswitch.io](https://ccswitch.io)**\n\n[English](README.md) | [中文](README_ZH.md) | 日本語 | [Deutsch](README_DE.md) | [Changelog](CHANGELOG.md)\n\n</div>\n\n## ❤️スポンサー\n\n> [ここに掲載しませんか?](mailto:farion1231@gmail.com)\n\n<details open>\n<summary>クリックで折りたたむ</summary>\n\n[![MiniMax](assets/partners/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)\n\nMiniMax-M2.7 は、自律的進化と実世界の生産性向上のために設計された次世代大規模言語モデルです。従来のモデルとは異なり、M2.7 はエージェントチーム、動的ツール使用、強化学習ループを通じて自身の改善に積極的に参加します。ソフトウェアエンジニアリングにおいて優れた性能を発揮し(SWE-Pro で 56.22%、VIBE-Pro で 55.6%、Terminal Bench 2 で 57.0%)、複雑なオフィスワークフローにも秀でており、GDPval-AA で 1495 ELO のリーディングスコアを達成しています。Word・Excel・PowerPoint の高忠実度編集と、40 以上の複雑なスキルにわたる 97% の遵守率により、M2.7 は AI ネイティブなワークフローと組織構築の新基準を打ち立てます。\n\n[こちら](https://platform.minimax.io/subscribe/coding-plan?code=ClLhgxr2je&source=link)から MiniMax Token Plan の限定 12% オフを入手!\n\n---\n\n<table>\n<tr>\n<td width=\"180\"><a href=\"https://www.packyapi.com/register?aff=cc-switch\"><img src=\"assets/partners/logos/packycode.png\" alt=\"PackyCode\" width=\"150\"></a></td>\n<td>PackyCode のご支援に感謝します!PackyCode は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームです。本ソフト利用者向けに特別割引があります:<a href=\"https://www.packyapi.com/register?aff=cc-switch\">このリンク</a>で登録し、チャージ時に「cc-switch」クーポンを入力すると 10% オフになります。</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://aigocode.com/invite/CC-SWITCH\"><img src=\"assets/partners/logos/aigocode.png\" alt=\"AIGoCode\" width=\"150\"></a></td>\n<td>本プロジェクトは AIGoCode のスポンサー提供でお届けしています。AIGoCode は、Claude Code・Codex・最新の Gemini モデルを統合したオールインワンのAIコーディングプラットフォームで、安定性・高速性・コストパフォーマンスに優れた開発サービスを提供します。柔軟なサブスクリプションプランを備え、レスポンスも非常に高速です。さらに、CC Switch ユーザー向けの特典として、<a href=\"https://aigocode.com/invite/CC-SWITCH\">このリンク</a>から登録すると、初回チャージ時に10%分のボーナスクレジットが付与されます!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.aicodemirror.com/register?invitecode=9915W3\"><img src=\"assets/partners/logos/aicodemirror.jpg\" alt=\"AICodeMirror\" width=\"150\"></a></td>\n<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定リレーサービスを提供しており、エンタープライズ級の同時接続、迅速な請求書発行、24時間年中無休の専用テクニカルサポートを備えています。\nClaude Code / Codex / Gemini 公式チャンネルが最安で元価格の 38% / 2% / 9%、チャージ時にはさらに割引!AICodeMirror は CC Switch ユーザー向けに特別特典を用意:<a href=\"https://www.aicodemirror.com/register?invitecode=9915W3\">このリンク</a>から登録すると初回チャージ 20% オフ、法人のお客様は最大 25% オフ!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.shengsuanyun.com/?from=CH_4HHXMRYF\"><img src=\"assets/partners/logos/shengsuanyun.png\" alt=\"Shengsuanyun\" width=\"150\"></a></td>\n<td>胜算雲(Shengsuanyun)のご支援に感謝します!胜算雲は AI ネイティブチーム向けのスーパーファクトリーであり、産業グレードの AI タスク並列実行プラットフォームです。モデルマーケットプレイスでは Claude、ChatGPT、Gemini をはじめとする国内外の LLM およびマルチメディアモデルの計算リソースを集約・直接提供。リバースエンジニアリングや品質低下は一切なく、プラットフォーム全体のモデル SLA 可用性は 99.7% に達し、<a href=\"https://watch.shengsuanyun.com/status/shengsuanyun\">監視ダッシュボード</a>は常時グリーン表示です。さらにエンタープライズ向けカスタムゲートウェイを提供し、チームのきめ細かなコスト・権限管理、スマートルーティング、セキュリティ保護、BYOK(自社キー持ち込み)ホスティングを実現します。従量課金およびトークンプラン(近日公開)対応で、請求書発行にも対応。<a href=\"https://www.shengsuanyun.com/?from=CH_4HHXMRYF\">このリンク</a>から新規登録すると 10 元分のクレジットと初回チャージ 10% ボーナスが付与されます。</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/\"><img src=\"assets/partners/logos/pateway.png\" alt=\"PatewayAI\" width=\"150\"></a></td>\n<td>PatewayAI のご支援に感謝します!PatewayAI はヘビーな AI 開発者向けに、公式直結の高品質モデル API 中継サービスを専門に提供するプロバイダーです。Claude シリーズ全モデルおよび Codex シリーズに対応し、100% 公式ソースから直接提供。混ぜ物・水増しは一切なく、検証も歓迎します。課金は透明で、トークン単位の請求書を 1 件ずつ照合可能です。\nエンタープライズ級の高同時接続にも対応し、法人のお客様には専用の管理プラットフォームを提供。正式契約および請求書発行に対応しており、詳細は公式サイトの連絡先よりお問い合わせください。\n現在、<a href=\"https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/\">このリンク</a>からご登録いただくと $3 のトライアルクレジットを進呈。チャージは最安で元価格の 60%、友達紹介は双方にボーナスが付与され、紹介報酬は最大 $150!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch\"><img src=\"assets/partners/logos/byteplus.png\" alt=\"BytePlus\" width=\"150\"></a></td>\n<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は ByteDance がグローバル市場向けに独自開発したフルモーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャを基盤に、テキスト・画像・音声・動画の統合的な理解と生成をサポートします。エージェント連携をネイティブに実現し、強力な推論、長時間タスクの実行、ツール統合、コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど幅広いシナリオに適用可能で、マルチモーダル認識、エンドツーエンドの複雑なタスク遂行、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて手軽に利用・デプロイできます。<a href=\"https://www.byteplus.com/en/product/modelark?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch\">このリンク</a>からご登録いただくと、モデルごとに 500,000 トークンの無料推論クォータを進呈します。</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://cloud.siliconflow.cn/i/drGuwc9k\"><img src=\"assets/partners/logos/silicon_en.jpg\" alt=\"SiliconFlow\" width=\"150\"></a></td>\n<td>SiliconFlow のご支援に感謝します!SiliconFlow は高性能 AI インフラストラクチャおよびモデル API プラットフォームで、言語・音声・画像・動画モデルへの高速かつ信頼性の高いアクセスをワンストップで提供します。従量課金制、豊富なマルチモーダルモデル対応、高速推論、エンタープライズグレードの安定性を備え、開発者やチームがより効率的に AI アプリケーションを構築・拡張できるようサポートします。<a href=\"https://cloud.siliconflow.cn/i/drGuwc9k\">このリンク</a>から登録し、本人確認を完了すると、プラットフォーム内の全モデルで利用可能な ¥16 のボーナスクレジットが付与されます。SiliconFlow は OpenClaw にも対応しており、SiliconFlow の API キーを接続することで主要な AI モデルを無料で呼び出すことができます。</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://cubence.com/signup?code=CCSWITCH&source=ccs\"><img src=\"assets/partners/logos/cubence.png\" alt=\"Cubence\" width=\"150\"></a></td>\n<td>Cubence のご支援に感謝します!Cubence は Claude Code、Codex、Gemini などのリレーサービスを提供する信頼性の高い API 中継プラットフォームで、従量課金や月額プランなど柔軟な料金体系を提供しています。CC Switch ユーザー向けの特別割引:<a href=\"https://cubence.com/signup?code=CCSWITCH&source=ccs\">このリンク</a>で登録し、チャージ時に「CCSWITCH」クーポンを入力すると、毎回 10% オフになります!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.dmxapi.cn/register?aff=bUHu\"><img src=\"assets/partners/logos/dmx-en.jpg\" alt=\"DMXAPI\" width=\"150\"></a></td>\n<td>DMXAPI のご支援に感謝します!DMXAPI は 200 社以上の企業ユーザーにグローバル大規模モデル API サービスを提供しています。1 つの API キーで全世界のモデルにアクセス可能。即時請求書発行、同時接続数無制限、最低 $0.15 から、24 時間年中無休のテクニカルサポート。GPT/Claude/Gemini が全て 32% オフ、国内モデルは 20〜50% オフ、Claude Code 専用モデルは 66% オフ実施中!<a href=\"https://www.dmxapi.cn/register?aff=bUHu\">登録はこちら</a></td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch\"><img src=\"assets/partners/logos/ucloud.png\" alt=\"Compshare\" width=\"150\"></a></td>\n<td>Compshare のご支援に感謝します!Compshare は UCloud 傘下の AI クラウドプラットフォームで、国内外の安定した包括的なモデル API を 1 つのキーだけで利用可能。月額・都度課金のコストパフォーマンスに優れた国内モデル Coding Plan パッケージを提供し、公式リレーによる安定した海外モデルも利用できます。Claude Code、Codex および API アクセスに対応。エンタープライズ級の高同時接続、24 時間年中無休のテクニカルサポート、セルフサービス請求書発行に対応。<a href=\"https://www.compshare.cn/coding-plan?ytag=GPU_YY_YX_git_cc-switch\">こちらのリンク</a>から登録すると、無料で 5 元分のプラットフォーム体験クレジットがもらえます!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://crazyrouter.com/register?aff=OZcm&ref=cc-switch\"><img src=\"assets/partners/logos/crazyrouter.png\" alt=\"Crazyrouter\" width=\"150\"></a></td>\n<td>Crazyrouter のご支援に感謝します!Crazyrouter は高性能 AI API アグリゲーションプラットフォームです。1 つの API キーで Claude Code、Codex、Gemini CLI など 300 以上のモデルにアクセス可能。全モデルが公式価格の 55% で利用でき、自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。CC Switch ユーザー向けの限定特典:<a href=\"https://crazyrouter.com/register?aff=OZcm&ref=cc-switch\">こちらのリンク</a>から登録後、カスタマーサポートまでご連絡いただくと <strong>$2 の無料クレジット</strong> を受け取れます。さらに初回チャージ時にプロモコード `CCSWITCH` を入力すると <strong>30% のボーナスクレジット</strong> が追加されます!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.right.codes/register?aff=CCSWITCH\"><img src=\"assets/partners/logos/rightcode.jpg\" alt=\"RightCode\" width=\"150\"></a></td>\n<td>本プロジェクトへのご支援として、Right Code にご協賛いただき誠にありがとうございます。Right Code は、Claude Code、Codex、Gemini などのモデル向け中継サービスを安定して提供しており、従量課金と月額プランの 2 つの料金体系から選択できます。チャージ後に請求書の発行が可能で、法人・チームのお客様には専任担当による個別対応も行っています。さらに、CC Switch ユーザー向けの特別優待として、<a href=\"https://www.right.codes/register?aff=CCSWITCH\">こちらのリンク</a>から登録すると、チャージのたびに実際の支払額の 5% 相当の従量課金クレジットが付与されます。</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.sssaicode.com/register?ref=DCP0SM\"><img src=\"assets/partners/logos/sssaicode.png\" alt=\"SSSAiCode\" width=\"150\"></a></td>\n<td>SSSAiCode のご支援に感謝します!SSSAiCode は安定性と信頼性に優れた API 中継サービスで、安定的で信頼性が高く、手頃な価格の Claude・Codex モデルサービスを提供しています。当日の迅速な請求書発行をサポート。CC Switch ユーザー向けの特別特典:<a href=\"https://www.sssaicode.com/register?ref=DCP0SM\">こちらのリンク</a>から登録すると、毎回のチャージで $10 の追加ボーナスを受けられます!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.micuapi.ai/register?aff=aOYQ\"><img src=\"assets/partners/logos/mikubanner.svg\" alt=\"Micu\" width=\"150\"></a></td>\n<td>Micu API のご支援に感謝します!Micu API は、最高のコストパフォーマンスと高い安定性を追求するグローバル大規模言語モデル中継サービスプロバイダーです。法人企業がバックアップしており、サービス停止のリスクを排除、迅速な正規請求書発行に対応!「試行コストゼロ」をモットーに、最低 1 元からチャージ可能で手数料無料、いつでも返金可能!CC Switch ユーザー向けの限定特典:<a href=\"https://www.micuapi.ai/register?aff=aOYQ\">こちらのリンク</a>から登録し、チャージ時にプロモコード「ccswitch」を入力すると <strong>10% 割引</strong> が適用されます!</td>\n</tr>\n<tr>\n<td width=\"180\"><a href=\"https://lemondata.cc/r/FFX1ZDUP\"><img src=\"assets/partners/logos/lemondata.png\" alt=\"LemonData\" width=\"150\"></a></td>\n<td>LemonData のご支援に感謝します!LemonData は高性能 AI API アグリゲーションプラットフォームで、GPT、Claude、Gemini、DeepSeek など 300 以上のモデルに 1 つの API キーでアクセス可能。全モデルが公式価格の 30〜70% オフで自動フェイルオーバー、スマートルーティング、無制限同時接続に対応。新規ユーザーは登録だけで即座に $1 の無料クレジットを獲得 — <a href=\"https://lemondata.cc/r/FFX1ZDUP\">こちらのリンク</a>から登録してボーナスを獲得し、すぐに開発を始めましょう!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://ctok.ai\"><img src=\"assets/partners/logos/ctok.png\" alt=\"CTok\" width=\"150\"></a></td>\n<td>CTok.ai のご支援に感謝します!CTok.ai はワンストップ AI プログラミングツールサービスプラットフォームの構築に取り組んでいます。Claude Code のプロフェッショナルプランと技術コミュニティサービスを提供し、Google Gemini や OpenAI Codex にも対応しています。丁寧に設計されたプランと専門的な技術コミュニティを通じて、開発者に安定したサービス保証と継続的な技術サポートを提供し、AI アシストプログラミングを真の生産性ツールにします。<a href=\"https://ctok.ai\">こちら</a>から登録してください!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://console.claudeapi.com/register?aff=pCLD\"><img src=\"assets/partners/logos/claudeapi.png\" alt=\"ClaudeAPI\" width=\"150\"></a></td>\n<td>本プロジェクトは <a href=\"https://console.claudeapi.com/register?aff=pCLD\">Claude API</a> がスポンサーです。Claude API 直結 — わずか 3 分で Claude Code や Agent アプリに接続可能。新規ユーザーにはテストクレジットを提供しています。Anthropic 公式キーおよび AWS Bedrock 公式チャネルに基づいており、リバースエンジニアリングや性能劣化はありません。Opus / Sonnet / Haiku の全モデルラインナップをサポートし、Tool Use や 1M コンテキストなどの公式機能をすべて保持しています。Claude Code ヘビーユーザー、Agent エンジニア、企業技術チームに最適です。請求書発行およびチーム対応が可能です。<a href=\"https://console.claudeapi.com/register?aff=pCLD\">こちら</a>から登録してください!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://claudecn.top\"><img src=\"assets/partners/logos/claudecn.jpg\" alt=\"ClaudeCN\" width=\"150\"></a></td>\n<td>本プロジェクトのスポンサーである ClaudeCN に感謝いたします!ClaudeCN は、実体のある企業によって運営されるエンタープライズ向け AI ゲートウェイプラットフォームです。Claude、GPT、DeepSeek など主要モデルへの高可用な商用 API アクセスを提供し、企業の調達プロセスにも対応 — 法人振込や正式契約に対応し、コンプライアンス面でも安心してご利用いただけます。<a href=\"https://claudecn.top\">こちら</a>からご登録ください!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://runapi.co\"><img src=\"assets/partners/logos/runapi.jpg\" alt=\"RunAPI\" width=\"150\"></a></td>\n<td>本プロジェクトのスポンサーである RunAPI に感謝いたします!RunAPI は高効率で安定した AI モデル API ゲートウェイです。一つの API Key で、OpenAI、Claude、Gemini、DeepSeek、Grok など 150 種類以上の主要モデルにアクセス可能。料金は公式価格の最大 10%、安定性にも優れ、Claude Code や OpenClaw などのツールとシームレスに連携できます。CC Switch ユーザー限定特典:ご登録後にカスタマーサポートへご連絡いただくと、14 元の無料クレジットを進呈いたします。<a href=\"https://runapi.co\">こちら</a>からご登録ください!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://apikey.fun/register?aff=CCSwitch\"><img src=\"assets/partners/logos/apikey_banner.png\" alt=\"APIKEY.FUN\" width=\"150\"></a></td>\n<td>APIKEY.FUN のご支援に感謝します!APIKEY.FUN は、企業および個人開発者向けに安定・高効率・低コストな AI モデル API 接続サービスを提供する、プロフェッショナルなエンタープライズ級 AI リレープラットフォームです。Claude、OpenAI、Gemini などの主要人気モデルに対応し、料金は公式価格の 7% から利用できます。本プロジェクトの<a href=\"https://apikey.fun/register?aff=CCSwitch\">専用リンク</a>から登録すると、最大で<strong>チャージ永久 5% オフ</strong>の特別優待も受けられます。</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://apinebula.com/02rw5X\"><img src=\"assets/partners/logos/apinebula_banner.png\" alt=\"APINebula\" width=\"150\"></a></td>\n<td>本プロジェクトは「APINEBULA」のスポンサーシップにより運営されています!APINEBULA は、「銀河録像局」傘下のエンタープライズ向け AI 統合プラットフォームです。大手の豊富なリソースを背景に、開発者、チーム、そして企業ユーザーの皆様へ、安定性とコストパフォーマンスに優れた大規模言語モデル(LLM)の API 連携サービスを提供しています。Claude、GPT、Gemini をはじめとする世界中の主要なフルスペック(満血)モデルを 1 つの API に集約。世界トップクラスの AI モデルを、<strong>最大 90% OFF(元の価格の 1 割〜)</strong>という圧倒的な低価格でご利用いただけます。また、企業向けの高度な並行処理(高コンカレンシー)、正式な契約締結、法人口座振り込み、請求書・領収書発行など、ビジネス利用に必要なサポートも万全です。AI プログラミング、AI エージェント開発、業務システムへの統合など、様々なシーンに最適です。<a href=\"https://apinebula.com/02rw5X\">こちらのリンク</a>から登録し、チャージ時にプロモコード <strong>「ccswitch」を入力すると、さらに 10% OFF</strong> の割引特典が適用されます!</td>\n</tr>\n\n<tr>\n<td width=\"180\"><a href=\"https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch\"><img src=\"assets/partners/logos/atlascloud_banner.png\" alt=\"Atlas Cloud\" width=\"150\"></a></td>\n<td>Atlas Cloud は、1 つの API で動画・画像生成や LLM(大規模言語モデル)を利用できる全モーダル対応の AI 推論プラットフォームです。複数のベンダーを個別に管理する手間を省き、一度の接続で 300 以上の厳選されたマルチモーダルモデルにアクセスできます。より低コストで API を利用できる、開発者向けの新しい<a href=\"https://www.atlascloud.ai/coding-plan?utm_source=github&utm_campaign=cc-switch\">「コーディングプラン」</a>プロモーションをぜひチェックしてください!</td>\n</tr>\n\n</table>\n\n</details>\n\n## CC Switch を選ぶ理由\n\n最新の AI コーディングは Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes などのツールに依存していますが、各ツールの設定形式はバラバラです。API プロバイダを切り替えるたびに JSON、TOML、`.env` ファイルを手動で編集する必要があり、複数ツール間で MCP や Skills を統一的に管理する手段もありません。\n\n**CC Switch** は、対応する AI ツールを 1 つのデスクトップアプリで一元管理できます。設定ファイルを手作業で編集する代わりに、ワンクリックでプロバイダをインポートし、瞬時に切り替えられるビジュアルインターフェースを提供します。50 以上の組み込みプリセット、統一 MCP・Skills 管理、システムトレイからの即時切り替え機能を搭載。すべてはアトミック書き込みによる信頼性の高い SQLite データベースに支えられており、設定の破損を防ぎます。\n\n- **1 つのアプリで 7 つのツール** -- Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes を単一インターフェースで管理\n- **手動編集は不要** -- AWS Bedrock、NVIDIA NIM、コミュニティリレーなど 50 以上のプロバイダプリセットを内蔵。選んで切り替えるだけ\n- **統一 MCP・Skills 管理** -- 1 つのパネルで Claude、Codex、Gemini、OpenCode、Hermes の MCP サーバーと Skills を双方向同期で管理\n- **システムトレイでクイック切り替え** -- トレイメニューから即座にプロバイダを切り替え。アプリを開く必要なし\n- **クラウド同期** -- Dropbox、OneDrive、iCloud、または WebDAV サーバー経由でデバイス間のプロバイダデータを同期\n- **クロスプラットフォーム** -- Tauri 2 で構築された Windows、macOS、Linux 対応のネイティブデスクトップアプリ\n- **便利ツール内蔵** -- 初回起動時のログイン確認、署名バイパス、プラグイン拡張の同期など、さまざまなユーティリティを搭載\n\n## スクリーンショット\n\n| メイン画面 | プロバイダ追加 |\n| :-------------------------------------------: | :----------------------------------------------: |\n| ![メイン画面](assets/screenshots/main-ja.png) | ![プロバイダ追加](assets/screenshots/add-ja.png) |\n\n## 特長\n\n[完全な更新履歴](CHANGELOG.md) | [リリースノート](docs/release-notes/v3.16.0-ja.md)\n\n### プロバイダ管理\n\n- **7 つの対応ツール、50 以上のプリセット** -- Claude Code、Claude Desktop、Codex、Gemini CLI、OpenCode、OpenClaw、Hermes。キーをコピーしてワンクリックでインポート\n- **ユニバーサルプロバイダ** -- 1 つの設定を Claude Code、Codex、Gemini CLI に同期\n- ワンクリック切り替え、システムトレイクイックアクセス、ドラッグ&ドロップ並び替え、インポート/エクスポート\n\n### プロキシ & フェイルオーバー\n\n- **ローカルプロキシのホットスイッチ** -- フォーマット変換、自動フェイルオーバー、サーキットブレーカー、プロバイダヘルスモニタリング、リクエストレクティファイア\n- **アプリレベルのテイクオーバー** -- Claude、Codex、Gemini を個別にプロキシ経由でルーティング、プロバイダ単位で設定可能\n\n### MCP、Prompts & Skills\n\n- **統一 MCP パネル** -- Claude、Codex、Gemini、OpenCode、Hermes の MCP サーバーを管理、双方向同期、Deep Link インポート対応\n- **Prompts** -- Markdown エディタ、クロスアプリ同期(CLAUDE.md / AGENTS.md / GEMINI.md)、バックフィル保護\n- **Skills** -- GitHub リポジトリまたは ZIP ファイルからワンクリックインストール、カスタムリポジトリ管理、シンボリックリンクとファイルコピーに対応\n\n### 使用量 & コストトラッキング\n\n- **使用量ダッシュボード** -- プロバイダ横断で支出・リクエスト数・トークン使用量を追跡、トレンドチャート、詳細リクエストログ、カスタムモデル価格設定\n\n### Session Manager & ワークスペース\n\n- 対応するセッションソースの会話履歴を閲覧・検索・復元\n- **ワークスペースエディタ**(OpenClaw)-- エージェントファイル(AGENTS.md、SOUL.md など)を Markdown プレビュー付きで編集\n\n### システム & プラットフォーム\n\n- **クラウド同期** -- カスタム設定ディレクトリ(Dropbox、OneDrive、iCloud、NAS)および WebDAV サーバー同期\n- **Deep Link** (`ccswitch://`) -- URL 経由でプロバイダ、MCP サーバー、Prompts、Skills をワンクリックインポート\n- ダーク / ライト / システムテーマ、自動起動、自動アップデーター、アトミック書き込み、自動バックアップ、多言語対応(簡体中文/繁體中文/英/日)\n\n## よくある質問\n\n<details>\n<summary><strong>CC Switch はどの AI ツールに対応していますか?</strong></summary>\n\nCC Switch は **Claude Code**、**Claude Desktop**、**Codex**、**Gemini CLI**、**OpenCode**、**OpenClaw**、**Hermes** の 7 つのツールに対応しています。各ツールに専用のプロバイダプリセットと設定管理が用意されています。\n\n</details>\n\n<details>\n<summary><strong>プロバイダを切り替えた後、ターミナルの再起動は必要ですか?</strong></summary>\n\nほとんどのツールでは、はい。変更を反映するにはターミナルまたは CLI ツールを再起動してください。ただし **Claude Code** は例外で、現在プロバイダデータのホットスイッチに対応しており、再起動は不要です。\n\n</details>\n\n<details>\n<summary><strong>プロバイダを切り替えた後、プラグイン設定が消えてしまいました。どうすればよいですか?</strong></summary>\n\nCC Switch には「共有設定スニペット」機能があり、APIキーやエンドポイント以外の共通データをプロバイダ間で引き継ぐことができます。「プロバイダ編集」→「共有設定パネル」→「現在のプロバイダから抽出」をクリックして、すべての共通データを保存してください。新しいプロバイダを作成する際に「共有設定を書き込む」にチェック(デフォルトで有効)を入れれば、プラグインなどのデータが新しいプロバイダ設定に含まれます。すべての設定項目は、アプリ初回起動時にインポートされたデフォルトプロバイダに保存されており、失われることはありません。\n\n</details>\n\n<details>\n<summary><strong>macOS のインストールについて</strong></summary>\n\nCC Switch の macOS 版は Apple によるコード署名と公証が完了しています。直接ダウンロードしてインストールできます — 追加の手順は不要です。`.dmg` インストーラの使用を推奨します。\n\n</details>\n\n<details>\n<summary><strong>現在アクティブなプロバイダを削除できないのはなぜですか?</strong></summary>\n\nCC Switch は「最小限の介入」という設計原則に従っています。アプリをアンインストールしても、CLI ツールは正常に動作し続けます。すべての設定を削除すると対応する CLI ツールが使用できなくなるため、システムは常にアクティブな設定を 1 つ保持します。特定の CLI ツールをあまり使用しない場合は、設定で非表示にできます。公式ログインに戻す方法は、次の質問をご覧ください。\n\n</details>\n\n<details>\n<summary><strong>公式ログインに戻すにはどうすればよいですか?</strong></summary>\n\nプリセットリストから公式プロバイダを追加してください。切り替え後、ログアウト/ログインのフローを実行すれば、以降は公式プロバイダとサードパーティプロバイダを自由に切り替えられます。Codex では異なる公式プロバイダ間の切り替えに対応しており、複数の Plus アカウントや Team アカウントの切り替えに便利です。\n\n</details>\n\n<details>\n<summary><strong>データはどこに保存されますか?</strong></summary>\n\n- **データベース**: `~/.cc-switch/cc-switch.db`(SQLite -- プロバイダ、MCP、Prompts、Skills)\n- **ローカル設定**: `~/.cc-switch/settings.json`(デバイスレベルの UI 設定)\n- **バックアップ**: `~/.cc-switch/backups/`(自動ローテーション、最新 10 件を保持)\n- **Skills**: `~/.cc-switch/skills/`(デフォルトでシンボリックリンクにより対応アプリに接続)\n- **Skill バックアップ**: `~/.cc-switch/skill-backups/`(アンインストール前に自動作成、最新 20 件を保持)\n\n</details>\n\n## ドキュメント\n\n各機能の詳しい使い方については、**[ユーザーマニュアル](docs/user-manual/ja/README.md)** をご覧ください。プロバイダ管理、MCP/Prompts/Skills、プロキシとフェイルオーバーなど、すべての機能を網羅しています。\n\n## クイックスタート\n\n### 基本的な使い方\n\n1. **プロバイダ追加**: 「Add Provider」をクリック → プリセットを選ぶかカスタム設定を作成\n2. **プロバイダ切り替え**:\n - メイン UI: プロバイダを選択 → 「Enable」をクリック\n - システムトレイ: プロバイダ名をクリック(即時反映)\n3. **反映**: ターミナルまたは対応する CLI ツールを再起動して適用(Claude Code は再起動不要)\n4. **公式設定に戻す**: 「Official Login」プリセットを追加し、CLI ツールを再起動してログイン/OAuth フローを実行\n\n### MCP、Prompts、Skills & Sessions\n\n- **MCP**: 「MCP」ボタンをクリック → テンプレートまたはカスタム設定でサーバーを追加 → アプリごとの同期をトグルで切り替え\n- **Prompts**: 「Prompts」をクリック → Markdown エディタでプリセットを作成 → 有効化してライブファイルに同期\n- **Skills**: 「Skills」をクリック → GitHub リポジトリを閲覧 → 対応アプリへワンクリックでインストール\n- **Sessions**: 「Sessions」をクリック → 対応するセッションソースの会話履歴を閲覧・検索・復元\n\n> **補足**: 初回起動時に、既存の CLI ツール設定を手動でインポートしてデフォルトプロバイダとして使用できます。\n\n## ダウンロード & インストール\n\n### システム要件\n\n- **Windows**: Windows 10 以上\n- **macOS**: macOS 12 (Monterey) 以上\n- **Linux**: Ubuntu 22.04+ / Debian 11+ / Fedora 34+ など主要ディストリビューション\n\n### Windows ユーザー\n\n[Releases](../../releases) ページから最新版の `CC-Switch-v{version}-Windows.msi` インストーラー、またはポータブル版 `CC-Switch-v{version}-Windows-Portable.zip` をダウンロード。\n\n### macOS ユーザー\n\n**方法 1: Homebrew でインストール(推奨)**\n\n```bash\nbrew install --cask cc-switch\n```\n\nアップデート:\n\n```bash\nbrew upgrade --cask cc-switch\n```\n\n**方法 2: 手動ダウンロード**\n\n[Releases](../../releases) から `CC-Switch-v{version}-macOS.zip` をダウンロードして展開。\n\n> **注意**: 開発者アカウント未登録のため、初回起動時に「開発元を確認できません」と表示される場合があります。一度閉じてから「システム設定」→「プライバシーとセキュリティ」→「このまま開く」をクリックしてください。以降は通常通り起動できます。\n\n### Arch Linux ユーザー\n\n**paru でインストール(推奨)**\n\n```bash\nparu -S cc-switch-bin\n```\n\n### Linux ユーザー\n\n[Releases](../../releases) から最新版の Linux ビルドをダウンロード:\n\n- `CC-Switch-v{version}-Linux.deb`(Debian/Ubuntu)\n- `CC-Switch-v{version}-Linux.rpm`(Fedora/RHEL/openSUSE)\n- `CC-Switch-v{version}-Linux.AppImage`(汎用)\n\n> **Flatpak**:公式リリースには含まれていません。`.deb` から自分でビルドできます — 手順は [`flatpak/README.md`](flatpak/README.md) を参照してください。\n\n<details>\n<summary><strong>アーキテクチャ概要</strong></summary>\n\n### 設計原則\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│ Frontend (React + TS) │\n│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │\n│ │ Components │ │ Hooks │ │ TanStack Query │ │\n│ │ (UI) │──│ (Bus. Logic) │──│ (Cache/Sync) │ │\n│ └─────────────┘ └──────────────┘ └──────────────────┘ │\n└────────────────────────┬────────────────────────────────────┘\n │ Tauri IPC\n┌────────────────────────▼────────────────────────────────────┐\n│ Backend (Tauri + Rust) │\n│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │\n│ │ Commands │ │ Services │ │ Models/Config │ │\n│ │ (API Layer) │──│ (Bus. Layer) │──│ (Data) │ │\n│ └─────────────┘ └──────────────┘ └──────────────────┘ │\n└─────────────────────────────────────────────────────────────┘\n```\n\n**コア設計パターン**\n\n- **SSOT** (Single Source of Truth): すべてのデータを `~/.cc-switch/cc-switch.db`(SQLite)に集約\n- **二層ストレージ**: 同期データは SQLite、デバイスデータは JSON\n- **双方向同期**: 切り替え時はライブファイルへ書き込み、編集時はアクティブプロバイダから逆同期\n- **アトミック書き込み**: 一時ファイル + rename パターンで設定破損を防止\n- **並行安全**: Mutex で保護された DB 接続でレースコンディションを防止\n- **レイヤードアーキテクチャ**: Commands → Services → DAO → Database を明確に分離\n\n**主要コンポーネント**\n\n- **ProviderService**: プロバイダの CRUD、切り替え、バックフィル、ソート\n- **McpService**: MCP サーバー管理、インポート/エクスポート、ライブファイル同期\n- **ProxyService**: ローカル Proxy モードのホットスイッチとフォーマット変換\n- **SessionManager**: 対応する全アプリの会話履歴閲覧\n- **ConfigService**: 設定のインポート/エクスポート、バックアップローテーション\n- **SpeedtestService**: API エンドポイントの遅延計測\n\n</details>\n\n<details>\n<summary><strong>開発ガイド</strong></summary>\n\n### 開発環境\n\n- Node.js 18+\n- pnpm 8+\n- Rust 1.85+\n- Tauri CLI 2.8+\n\n### 開発コマンド\n\n```bash\n# 依存関係をインストール\npnpm install\n\n# ホットリロード付き開発モード\npnpm dev\n\n# 型チェック\npnpm typecheck\n\n# コード整形\npnpm format\n\n# フォーマット検証\npnpm format:check\n\n# フロントエンド単体テスト\npnpm test:unit\n\n# ウォッチモード(開発に推奨)\npnpm test:unit:watch\n\n# アプリをビルド\npnpm build\n\n# デバッグビルド\npnpm tauri build --debug\n```\n\n### Rust バックエンド開発\n\n```bash\ncd src-tauri\n\n# Rust コード整形\ncargo fmt\n\n# clippy チェック\ncargo clippy\n\n# バックエンドテスト\ncargo test\n\n# 特定テストのみ実行\ncargo test test_name\n\n# test-hooks フィーチャー付きでテスト\ncargo test --features test-hooks\n```\n\n### テストガイド\n\n**フロントエンドテスト**:\n\n- テストフレームワークに **vitest** を使用\n- **MSW (Mock Service Worker)** で Tauri API 呼び出しをモック\n- コンポーネントテストに **@testing-library/react** を採用\n\n**テスト実行**:\n\n```bash\n# 全テストを実行\npnpm test:unit\n\n# ウォッチモード(自動再実行)\npnpm test:unit:watch\n\n# カバレッジレポート付き\npnpm test:unit --coverage\n```\n\n### 技術スタック\n\n**フロントエンド**: React 18 · TypeScript · Vite · TailwindCSS 3.4 · TanStack Query v5 · react-i18next · react-hook-form · zod · shadcn/ui · @dnd-kit\n\n**バックエンド**: Tauri 2.8 · Rust · serde · tokio · thiserror · tauri-plugin-updater/process/dialog/store/log\n\n**テスト**: vitest · MSW · @testing-library/react\n\n</details>\n\n<details>\n<summary><strong>プロジェクト構成</strong></summary>\n\n```\n├── src/ # フロントエンド (React + TypeScript)\n│ ├── components/\n│ │ ├── providers/ # プロバイダ管理\n│ │ ├── mcp/ # MCP パネル\n│ │ ├── prompts/ # Prompts 管理\n│ │ ├── skills/ # Skills 管理\n│ │ ├── sessions/ # Session Manager\n│ │ ├── proxy/ # Proxy モードパネル\n│ │ ├── openclaw/ # OpenClaw 設定パネル\n│ │ ├── settings/ # 設定 (Terminal/Backup/About)\n│ │ ├── deeplink/ # Deep Link インポート\n│ │ ├── env/ # 環境変数管理\n│ │ ├── universal/ # クロスアプリ設定\n│ │ ├── usage/ # 使用量統計\n│ │ └── ui/ # shadcn/ui コンポーネントライブラリ\n│ ├── hooks/ # カスタムフック(ビジネスロジック)\n│ ├── lib/\n│ │ ├── api/ # Tauri API ラッパー(型安全)\n│ │ └── query/ # TanStack Query 設定\n│ ├── locales/ # 翻訳 (zh/zh-TW/en/ja)\n│ ├── config/ # プリセット (providers/mcp)\n│ └── types/ # TypeScript 型定義\n├── src-tauri/ # バックエンド (Rust)\n│ └── src/\n│ ├── commands/ # Tauri コマンド層(ドメイン別)\n│ ├── services/ # ビジネスロジック層\n│ ├── database/ # SQLite DAO 層\n│ ├── proxy/ # Proxy モジュール\n│ ├── session_manager/ # セッション管理\n│ ├── deeplink/ # Deep Link 処理\n│ └── mcp/ # MCP 同期モジュール\n├── tests/ # フロントエンドテスト\n└── assets/ # スクリーンショット & パートナーリソース\n```\n\n</details>\n\n## 貢献\n\nIssue や提案を歓迎します!\n\nPR を送る前に以下をご確認ください:\n\n- 型チェック: `pnpm typecheck`\n- フォーマットチェック: `pnpm format:check`\n- 単体テスト: `pnpm test:unit`\n\n新機能の場合は、PR を送る前に Issue でディスカッションしてください。プロジェクトに合わない機能の PR はクローズされる場合があります。\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=farion1231/cc-switch&type=Date)](https://www.star-history.com/#farion1231/cc-switch&Date)\n\n## ライセンス\n\nMIT © Jason Young\n"}}, "src/config/claudeDesktopProviderPresets.ts": {"bab9119da3c9dbecaeca84f2a022c9b7ad8e52fe": {"t": 1785596181.4369383, "n": 29663, "h": "ab7d5b4f5c446e20", "c": "/**\n * Claude Desktop 预设供应商配置模板\n *\n * 形态与 Claude Code 预设不同:\n * - baseUrl 是顶级字段,而不是 settingsConfig.env.ANTHROPIC_BASE_URL\n * - 模型信息以\"Desktop 可见模型 ID → 上游模型\"表达,\n * 对应后端 ClaudeDesktopModelRoute 的 routeId / model\n *\n * 翻译来源:src/config/claudeProviderPresets.ts(排除 OAuth 与不兼容预设)\n */\nimport { ProviderCategory } from \"../types\";\nimport type { PresetTheme } from \"./claudeProviderPresets\";\n\nexport type ClaudeDesktopApiFormat =\n | \"anthropic\"\n | \"openai_chat\"\n | \"openai_responses\"\n | \"gemini_native\";\n\nexport interface ClaudeDesktopRoutePreset {\n routeId: string;\n upstreamModel: string;\n labelOverride?: string;\n supports1m: boolean;\n}\n\n/**\n * Claude Desktop 3P fail-all 校验只接受 `claude-(sonnet|opus|haiku)-*` 形式的\n * routeId(1.6259.1+,实测 2026-05-13)。所有预设工厂、表单角色下拉、\n * 后端 `next_catalog_safe_route_id` 都从此映射派生 routeId,避免散落硬编码。\n */\nexport const CLAUDE_DESKTOP_ROLE_ROUTE_IDS = {\n sonnet: \"claude-sonnet-4-6\",\n opus: \"claude-opus-4-8\",\n haiku: \"claude-haiku-4-5\",\n} as const;\n\nexport type ClaudeDesktopRoleId = keyof typeof CLAUDE_DESKTOP_ROLE_ROUTE_IDS;\n\nexport interface ClaudeDesktopProviderPreset {\n name: string;\n nameKey?: string;\n websiteUrl: string;\n apiKeyUrl?: string;\n category?: ProviderCategory;\n isPartner?: boolean;\n partnerPromotionKey?: string;\n\n baseUrl: string;\n apiKeyField?: \"ANTHROPIC_AUTH_TOKEN\" | \"ANTHROPIC_API_KEY\";\n\n mode: \"direct\" | \"proxy\";\n apiFormat?: ClaudeDesktopApiFormat;\n modelRoutes?: ClaudeDesktopRoutePreset[];\n providerType?: \"github_copilot\" | \"codex_oauth\";\n requiresOAuth?: boolean;\n\n endpointCandidates?: string[];\n theme?: PresetTheme;\n icon?: string;\n iconColor?: string;\n}\n\nconst passthroughRoutes = (supports1m = false): ClaudeDesktopRoutePreset[] => [\n {\n routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet,\n upstreamModel: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet,\n supports1m,\n },\n {\n routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus,\n upstreamModel: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus,\n supports1m,\n },\n {\n routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku,\n upstreamModel: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku,\n supports1m,\n },\n];\n\nconst mappedRoutes = (\n sonnet: string,\n opus: string,\n haiku: string,\n supports1m = false,\n): ClaudeDesktopRoutePreset[] => [\n {\n routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet,\n upstreamModel: sonnet,\n supports1m,\n },\n {\n routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus,\n upstreamModel: opus,\n supports1m,\n },\n {\n routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku,\n upstreamModel: haiku,\n supports1m,\n },\n];\n\n/**\n * 非 Claude 上游模型用此工厂:route ID 使用 Claude Desktop 能通过校验的\n * Sonnet/Opus/Haiku 路由,真实品牌名只写入 labelOverride 和 upstreamModel。\n */\nconst brandedRoutes = (\n sonnet: string,\n opus: string,\n haiku: string,\n supports1m = false,\n): ClaudeDesktopRoutePreset[] => {\n const seenUpstream = new Set<string>();\n return [\n { routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.sonnet, upstreamModel: sonnet },\n { routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.opus, upstreamModel: opus },\n { routeId: CLAUDE_DESKTOP_ROLE_ROUTE_IDS.haiku, upstreamModel: haiku },\n ]\n .map(({ routeId, upstreamModel }) => ({\n routeId,\n upstreamModel,\n labelOverride: upstreamModel,\n supports1m,\n }))\n .filter((route) => {\n if (seenUpstream.has(route.upstreamModel)) {\n return false;\n }\n seenUpstream.add(route.upstreamModel);\n return true;\n });\n};\n\nexport const claudeDesktopProviderPresets: ClaudeDesktopProviderPreset[] = [\n {\n name: \"Claude Desktop Official\",\n websiteUrl: \"https://claude.ai/download\",\n category: \"official\",\n baseUrl: \"\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n theme: {\n icon: \"claude\",\n backgroundColor: \"#D97757\",\n textColor: \"#FFFFFF\",\n },\n icon: \"anthropic\",\n iconColor: \"#D4915D\",\n },\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 category: \"aggregator\",\n baseUrl: \"https://router.shengsuanyun.com/api\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: mappedRoutes(\n \"anthropic/claude-sonnet-4.6\",\n \"anthropic/claude-opus-4.8\",\n \"anthropic/claude-haiku-4.5\",\n ),\n isPartner: true,\n partnerPromotionKey: \"shengsuanyun\",\n icon: \"shengsuanyun\",\n },\n {\n name: \"PatewayAI\",\n websiteUrl: \"https://pateway.ai\",\n apiKeyUrl: \"https://pateway.ai/?ch=etzpm8&aff=WB6M6F67#/\",\n category: \"third_party\",\n baseUrl: \"https://api.pateway.ai\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n isPartner: true,\n partnerPromotionKey: \"patewayai\",\n icon: \"pateway\",\n },\n {\n name: \"火山Agentplan\",\n websiteUrl:\n \"https://www.volcengine.com/activity/agentplan?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/agentplan?utm_campaign=hw&utm_content=ccswitch&utm_medium=devrel_tool_web&utm_source=OWO&utm_term=ccswitch\",\n category: \"cn_official\",\n baseUrl: \"https://ark.cn-beijing.volces.com/api/coding\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\n \"ark-code-latest\",\n \"ark-code-latest\",\n \"ark-code-latest\",\n ),\n icon: \"huoshan\",\n iconColor: \"#3370FF\",\n isPartner: true,\n partnerPromotionKey: \"volcengine_agentplan\",\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 category: \"cn_official\",\n baseUrl: \"https://ark.ap-southeast.bytepluses.com/api/coding\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\n \"ark-code-latest\",\n \"ark-code-latest\",\n \"ark-code-latest\",\n ),\n icon: \"byteplus\",\n iconColor: \"#3370FF\",\n isPartner: true,\n partnerPromotionKey: \"byteplus\",\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 category: \"cn_official\",\n baseUrl: \"https://ark.cn-beijing.volces.com/api/compatible\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\n \"doubao-seed-2-0-code-preview-latest\",\n \"doubao-seed-2-0-code-preview-latest\",\n \"doubao-seed-2-0-code-preview-latest\",\n ),\n isPartner: true,\n partnerPromotionKey: \"doubaoseed\",\n icon: \"doubao\",\n iconColor: \"#3370FF\",\n },\n {\n name: \"Gemini Native\",\n websiteUrl: \"https://ai.google.dev/gemini-api\",\n apiKeyUrl: \"https://aistudio.google.com/app/apikey\",\n category: \"third_party\",\n baseUrl: \"https://generativelanguage.googleapis.com\",\n apiKeyField: \"ANTHROPIC_API_KEY\",\n mode: \"proxy\",\n apiFormat: \"gemini_native\",\n modelRoutes: brandedRoutes(\n \"gemini-3.5-flash\",\n \"gemini-3.5-flash\",\n \"gemini-3.5-flash\",\n ),\n endpointCandidates: [\"https://generativelanguage.googleapis.com\"],\n icon: \"gemini\",\n iconColor: \"#4285F4\",\n },\n {\n name: \"GitHub Copilot\",\n websiteUrl: \"https://github.com/features/copilot\",\n category: \"third_party\",\n baseUrl: \"https://api.githubcopilot.com\",\n mode: \"proxy\",\n apiFormat: \"openai_chat\",\n providerType: \"github_copilot\",\n requiresOAuth: true,\n modelRoutes: brandedRoutes(\n \"claude-sonnet-4.6\",\n \"claude-sonnet-4.6\",\n \"claude-haiku-4.5\",\n ),\n icon: \"github\",\n iconColor: \"#000000\",\n },\n {\n name: \"Codex\",\n websiteUrl: \"https://openai.com/chatgpt/pricing\",\n category: \"third_party\",\n baseUrl: \"https://chatgpt.com/backend-api/codex\",\n mode: \"proxy\",\n apiFormat: \"openai_responses\",\n providerType: \"codex_oauth\",\n requiresOAuth: true,\n modelRoutes: brandedRoutes(\"gpt-5.5\", \"gpt-5.5\", \"gpt-5.4-mini\"),\n icon: \"openai\",\n iconColor: \"#000000\",\n },\n {\n name: \"DeepSeek\",\n websiteUrl: \"https://platform.deepseek.com\",\n category: \"cn_official\",\n baseUrl: \"https://api.deepseek.com/anthropic\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\n \"deepseek-v4-pro\",\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n ),\n icon: \"deepseek\",\n iconColor: \"#1E88E5\",\n },\n {\n name: \"OpenCode Go\",\n websiteUrl: \"https://opencode.ai\",\n category: \"third_party\",\n baseUrl: \"https://opencode.ai/zen/go\",\n mode: \"proxy\",\n apiFormat: \"openai_chat\",\n modelRoutes: brandedRoutes(\n \"deepseek-v4-flash\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-flash\",\n ),\n endpointCandidates: [\"https://opencode.ai/zen/go\"],\n icon: \"opencode\",\n iconColor: \"#211E1E\",\n },\n {\n name: \"Zhipu GLM\",\n websiteUrl: \"https://open.bigmodel.cn\",\n apiKeyUrl: \"https://www.bigmodel.cn/claude-code?ic=RRVJPB5SII\",\n category: \"cn_official\",\n baseUrl: \"https://open.bigmodel.cn/api/anthropic\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\"glm-5.1\", \"glm-5.1\", \"glm-5.1\"),\n icon: \"zhipu\",\n iconColor: \"#0F62FE\",\n },\n {\n name: \"Zhipu GLM en\",\n websiteUrl: \"https://z.ai\",\n apiKeyUrl: \"https://z.ai/subscribe?ic=8JVLJQFSKB\",\n category: \"cn_official\",\n baseUrl: \"https://api.z.ai/api/anthropic\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\"glm-5.1\", \"glm-5.1\", \"glm-5.1\"),\n icon: \"zhipu\",\n iconColor: \"#0F62FE\",\n },\n {\n name: \"Baidu Qianfan Coding Plan\",\n websiteUrl: \"https://cloud.baidu.com/product/qianfan_modelbuilder\",\n apiKeyUrl:\n \"https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application\",\n category: \"cn_official\",\n baseUrl: \"https://qianfan.baidubce.com/anthropic/coding\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\n \"qianfan-code-latest\",\n \"qianfan-code-latest\",\n \"qianfan-code-latest\",\n ),\n endpointCandidates: [\"https://qianfan.baidubce.com/anthropic/coding\"],\n icon: \"baidu\",\n iconColor: \"#2932E1\",\n },\n {\n name: \"Bailian\",\n websiteUrl: \"https://bailian.console.aliyun.com\",\n category: \"cn_official\",\n baseUrl: \"https://dashscope.aliyuncs.com/apps/anthropic\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n icon: \"bailian\",\n iconColor: \"#624AFF\",\n },\n {\n name: \"Bailian For Coding\",\n websiteUrl: \"https://bailian.console.aliyun.com\",\n category: \"cn_official\",\n baseUrl: \"https://coding.dashscope.aliyuncs.com/apps/anthropic\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n icon: \"bailian\",\n iconColor: \"#624AFF\",\n },\n {\n name: \"Kimi\",\n websiteUrl: \"https://platform.moonshot.cn/console\",\n category: \"cn_official\",\n baseUrl: \"https://api.moonshot.cn/anthropic\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\"kimi-k2.6\", \"kimi-k2.6\", \"kimi-k2.6\"),\n icon: \"kimi\",\n iconColor: \"#6366F1\",\n },\n {\n name: \"Kimi For Coding\",\n websiteUrl: \"https://www.kimi.com/code/docs/\",\n category: \"cn_official\",\n baseUrl: \"https://api.kimi.com/coding/\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n icon: \"kimi\",\n iconColor: \"#6366F1\",\n },\n {\n name: \"StepFun\",\n websiteUrl: \"https://platform.stepfun.com/step-plan\",\n apiKeyUrl: \"https://platform.stepfun.com/interface-key\",\n category: \"cn_official\",\n baseUrl: \"https://api.stepfun.com/step_plan\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\n \"step-3.5-flash-2603\",\n \"step-3.5-flash-2603\",\n \"step-3.5-flash-2603\",\n ),\n endpointCandidates: [\"https://api.stepfun.com/step_plan\"],\n icon: \"stepfun\",\n iconColor: \"#16D6D2\",\n },\n {\n name: \"StepFun en\",\n websiteUrl: \"https://platform.stepfun.ai/step-plan\",\n apiKeyUrl: \"https://platform.stepfun.ai/interface-key\",\n category: \"cn_official\",\n baseUrl: \"https://api.stepfun.ai/step_plan\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\n \"step-3.5-flash-2603\",\n \"step-3.5-flash-2603\",\n \"step-3.5-flash-2603\",\n ),\n endpointCandidates: [\"https://api.stepfun.ai/step_plan\"],\n icon: \"stepfun\",\n iconColor: \"#16D6D2\",\n },\n {\n name: \"ModelScope\",\n websiteUrl: \"https://modelscope.cn\",\n category: \"aggregator\",\n baseUrl: \"https://api-inference.modelscope.cn\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\n \"ZhipuAI/GLM-5.1\",\n \"ZhipuAI/GLM-5.1\",\n \"ZhipuAI/GLM-5.1\",\n ),\n icon: \"modelscope\",\n iconColor: \"#624AFF\",\n },\n {\n name: \"Longcat\",\n websiteUrl: \"https://longcat.chat/platform\",\n apiKeyUrl: \"https://longcat.chat/platform/api_keys\",\n category: \"cn_official\",\n baseUrl: \"https://api.longcat.chat/anthropic\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\n \"LongCat-Flash-Chat\",\n \"LongCat-Flash-Chat\",\n \"LongCat-Flash-Chat\",\n ),\n icon: \"longcat\",\n iconColor: \"#29E154\",\n },\n {\n name: \"MiniMax\",\n websiteUrl: \"https://platform.minimaxi.com\",\n apiKeyUrl: \"https://platform.minimaxi.com/subscribe/coding-plan\",\n category: \"cn_official\",\n baseUrl: \"https://api.minimaxi.com/anthropic\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\"MiniMax-M2.7\", \"MiniMax-M2.7\", \"MiniMax-M2.7\"),\n isPartner: true,\n partnerPromotionKey: \"minimax_cn\",\n theme: {\n backgroundColor: \"#f64551\",\n textColor: \"#FFFFFF\",\n },\n icon: \"minimax\",\n iconColor: \"#FF6B6B\",\n },\n {\n name: \"MiniMax en\",\n websiteUrl: \"https://platform.minimax.io\",\n apiKeyUrl: \"https://platform.minimax.io/subscribe/coding-plan\",\n category: \"cn_official\",\n baseUrl: \"https://api.minimax.io/anthropic\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\"MiniMax-M2.7\", \"MiniMax-M2.7\", \"MiniMax-M2.7\"),\n isPartner: true,\n partnerPromotionKey: \"minimax_en\",\n theme: {\n backgroundColor: \"#f64551\",\n textColor: \"#FFFFFF\",\n },\n icon: \"minimax\",\n iconColor: \"#FF6B6B\",\n },\n {\n name: \"BaiLing\",\n websiteUrl: \"https://alipaytbox.yuque.com/sxs0ba/ling/get_started\",\n category: \"cn_official\",\n baseUrl: \"https://api.tbox.cn/api/anthropic\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\"Ling-2.5-1T\", \"Ling-2.5-1T\", \"Ling-2.5-1T\"),\n },\n {\n name: \"AiHubMix\",\n websiteUrl: \"https://aihubmix.com\",\n apiKeyUrl: \"https://aihubmix.com\",\n category: \"aggregator\",\n baseUrl: \"https://aihubmix.com\",\n apiKeyField: \"ANTHROPIC_API_KEY\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\"https://aihubmix.com\", \"https://api.aihubmix.com\"],\n icon: \"aihubmix\",\n iconColor: \"#006FFB\",\n },\n {\n name: \"CherryIN\",\n websiteUrl: \"https://open.cherryin.ai\",\n apiKeyUrl: \"https://open.cherryin.ai/console/token\",\n category: \"aggregator\",\n baseUrl: \"https://open.cherryin.net\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: mappedRoutes(\n \"anthropic/claude-sonnet-4.6\",\n \"anthropic/claude-opus-4.8\",\n \"anthropic/claude-haiku-4.5\",\n ),\n endpointCandidates: [\"https://open.cherryin.net\"],\n icon: \"cherryin\",\n },\n {\n name: \"SiliconFlow\",\n websiteUrl: \"https://siliconflow.cn\",\n apiKeyUrl: \"https://cloud.siliconflow.cn/i/drGuwc9k\",\n category: \"aggregator\",\n baseUrl: \"https://api.siliconflow.cn\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\n \"Pro/MiniMaxAI/MiniMax-M2.7\",\n \"Pro/MiniMaxAI/MiniMax-M2.7\",\n \"Pro/MiniMaxAI/MiniMax-M2.7\",\n ),\n isPartner: true,\n partnerPromotionKey: \"siliconflow\",\n icon: \"siliconflow\",\n iconColor: \"#6E29F6\",\n },\n {\n name: \"SiliconFlow en\",\n websiteUrl: \"https://siliconflow.com\",\n apiKeyUrl: \"https://cloud.siliconflow.cn/i/drGuwc9k\",\n category: \"aggregator\",\n baseUrl: \"https://api.siliconflow.com\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\n \"MiniMaxAI/MiniMax-M2.7\",\n \"MiniMaxAI/MiniMax-M2.7\",\n \"MiniMaxAI/MiniMax-M2.7\",\n ),\n isPartner: true,\n partnerPromotionKey: \"siliconflow\",\n icon: \"siliconflow\",\n iconColor: \"#000000\",\n },\n {\n name: \"DMXAPI\",\n websiteUrl: \"https://www.dmxapi.cn\",\n apiKeyUrl: \"https://www.dmxapi.cn\",\n category: \"aggregator\",\n baseUrl: \"https://www.dmxapi.cn\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\"https://www.dmxapi.cn\", \"https://api.dmxapi.cn\"],\n isPartner: true,\n partnerPromotionKey: \"dmxapi\",\n },\n {\n name: \"PackyCode\",\n websiteUrl: \"https://www.packyapi.com\",\n apiKeyUrl: \"https://www.packyapi.com/register?aff=cc-switch\",\n category: \"third_party\",\n baseUrl: \"https://www.packyapi.com\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\n \"https://www.packyapi.com\",\n \"https://api-slb.packyapi.com\",\n ],\n isPartner: true,\n partnerPromotionKey: \"packycode\",\n icon: \"packycode\",\n },\n {\n name: \"APIKEY.FUN\",\n websiteUrl: \"https://apikey.fun\",\n apiKeyUrl: \"https://apikey.fun/register?aff=CCSwitch\",\n category: \"third_party\",\n baseUrl: \"https://api.apikey.fun\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\"https://api.apikey.fun\", \"https://slb.apikey.fun\"],\n isPartner: true,\n partnerPromotionKey: \"apikeyfun\",\n icon: \"apikeyfun\",\n },\n {\n name: \"APINebula\",\n websiteUrl: \"https://apinebula.com\",\n apiKeyUrl: \"https://apinebula.com/02rw5X\",\n category: \"third_party\",\n baseUrl: \"https://apinebula.com\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\"https://apinebula.com\"],\n isPartner: true,\n partnerPromotionKey: \"apinebula\",\n icon: \"apinebula\",\n },\n {\n name: \"AtlasCloud\",\n websiteUrl: \"https://www.atlascloud.ai/console/coding-plan\",\n apiKeyUrl: \"https://www.atlascloud.ai/console/coding-plan\",\n category: \"aggregator\",\n baseUrl: \"https://api.atlascloud.ai\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\"https://api.atlascloud.ai\"],\n isPartner: true,\n partnerPromotionKey: \"atlascloud\",\n icon: \"atlascloud\",\n },\n {\n name: \"SudoCode\",\n websiteUrl: \"https://sudocode.us\",\n apiKeyUrl: \"https://sudocode.us\",\n category: \"third_party\",\n baseUrl: \"https://sudocode.us\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\"https://sudocode.us\", \"https://sudocode.run\"],\n isPartner: true,\n partnerPromotionKey: \"sudocode\",\n icon: \"sudocode\",\n },\n {\n name: \"ClaudeAPI\",\n websiteUrl: \"https://claudeapi.com\",\n apiKeyUrl: \"https://console.claudeapi.com/register?aff=pCLD\",\n category: \"aggregator\",\n baseUrl: \"https://gw.claudeapi.com\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n isPartner: true,\n partnerPromotionKey: \"claudeapi\",\n icon: \"claudeapi\",\n },\n {\n name: \"ClaudeCN\",\n websiteUrl: \"https://claudecn.top\",\n apiKeyUrl: \"https://claudecn.top/register?aff=ccswitch\",\n category: \"third_party\",\n baseUrl: \"https://claudecn.top\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n isPartner: true,\n partnerPromotionKey: \"claudecn\",\n icon: \"claudecn\",\n },\n {\n name: \"RunAPI\",\n websiteUrl: \"https://runapi.co\",\n apiKeyUrl: \"https://runapi.co\",\n category: \"aggregator\",\n baseUrl: \"https://runapi.co\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n isPartner: true,\n partnerPromotionKey: \"runapi\",\n icon: \"runapi\",\n },\n {\n name: \"RelaxyCode\",\n websiteUrl: \"https://www.relaxycode.com\",\n apiKeyUrl: \"https://www.relaxycode.com/register\",\n category: \"third_party\",\n baseUrl: \"https://www.relaxycode.com\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n icon: \"relaxcode\",\n },\n {\n name: \"Cubence\",\n websiteUrl: \"https://cubence.com\",\n apiKeyUrl: \"https://cubence.com/signup?code=CCSWITCH&source=ccs\",\n category: \"third_party\",\n baseUrl: \"https://api.cubence.com\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\n \"https://api.cubence.com\",\n \"https://api-cf.cubence.com\",\n \"https://api-dmit.cubence.com\",\n \"https://api-bwg.cubence.com\",\n ],\n isPartner: true,\n partnerPromotionKey: \"cubence\",\n icon: \"cubence\",\n iconColor: \"#000000\",\n },\n {\n name: \"AIGoCode\",\n websiteUrl: \"https://aigocode.com\",\n apiKeyUrl: \"https://aigocode.com/invite/CC-SWITCH\",\n category: \"third_party\",\n baseUrl: \"https://api.aigocode.com\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\"https://api.aigocode.com\"],\n isPartner: true,\n partnerPromotionKey: \"aigocode\",\n icon: \"aigocode\",\n iconColor: \"#5B7FFF\",\n },\n {\n name: \"RightCode\",\n websiteUrl: \"https://www.right.codes\",\n apiKeyUrl: \"https://www.right.codes/register?aff=CCSWITCH\",\n category: \"third_party\",\n baseUrl: \"https://www.right.codes/claude\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n isPartner: true,\n partnerPromotionKey: \"rightcode\",\n icon: \"rc\",\n iconColor: \"#E96B2C\",\n },\n {\n name: \"AICodeMirror\",\n websiteUrl: \"https://www.aicodemirror.com\",\n apiKeyUrl: \"https://www.aicodemirror.com/register?invitecode=9915W3\",\n category: \"third_party\",\n baseUrl: \"https://api.aicodemirror.com/api/claudecode\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\n \"https://api.aicodemirror.com/api/claudecode\",\n \"https://api.claudecode.net.cn/api/claudecode\",\n ],\n isPartner: true,\n partnerPromotionKey: \"aicodemirror\",\n icon: \"aicodemirror\",\n iconColor: \"#000000\",\n },\n {\n name: \"CrazyRouter\",\n websiteUrl: \"https://www.crazyrouter.com\",\n apiKeyUrl: \"https://www.crazyrouter.com/register?aff=OZcm&ref=cc-switch\",\n category: \"third_party\",\n baseUrl: \"https://cn.crazyrouter.com\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\"https://cn.crazyrouter.com\"],\n isPartner: true,\n partnerPromotionKey: \"crazyrouter\",\n icon: \"crazyrouter\",\n iconColor: \"#000000\",\n },\n {\n name: \"SSSAiCode\",\n websiteUrl: \"https://www.sssaicode.com\",\n apiKeyUrl: \"https://www.sssaicode.com/register?ref=DCP0SM\",\n category: \"third_party\",\n baseUrl: \"https://node-hk.sssaicode.com/api\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\n \"https://node-hk.sssaicode.com/api\",\n \"https://claude2.sssaicode.com/api\",\n \"https://anti.sssaicode.com/api\",\n ],\n isPartner: true,\n partnerPromotionKey: \"sssaicode\",\n icon: \"sssaicode\",\n iconColor: \"#000000\",\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 category: \"aggregator\",\n baseUrl: \"https://api.modelverse.cn\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\"https://api.modelverse.cn\"],\n isPartner: true,\n partnerPromotionKey: \"ucloud\",\n icon: \"ucloud\",\n iconColor: \"#000000\",\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 category: \"aggregator\",\n baseUrl: \"https://cp.compshare.cn\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\"https://cp.compshare.cn\"],\n isPartner: true,\n partnerPromotionKey: \"ucloud\",\n icon: \"ucloud\",\n iconColor: \"#000000\",\n },\n {\n name: \"Micu\",\n websiteUrl: \"https://www.micuapi.ai\",\n apiKeyUrl: \"https://www.micuapi.ai/register?aff=aOYQ\",\n category: \"third_party\",\n baseUrl: \"https://www.micuapi.ai\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\"https://www.micuapi.ai\"],\n isPartner: true,\n partnerPromotionKey: \"micu\",\n icon: \"micu\",\n iconColor: \"#000000\",\n },\n {\n name: \"CTok.ai\",\n websiteUrl: \"https://ctok.ai\",\n apiKeyUrl: \"https://ctok.ai\",\n category: \"third_party\",\n baseUrl: \"https://api.ctok.ai\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n isPartner: true,\n partnerPromotionKey: \"ctok\",\n icon: \"ctok\",\n iconColor: \"#000000\",\n },\n {\n name: \"E-FlowCode\",\n websiteUrl: \"https://e-flowcode.cc\",\n apiKeyUrl: \"https://e-flowcode.cc\",\n category: \"third_party\",\n baseUrl: \"https://e-flowcode.cc\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n endpointCandidates: [\"https://e-flowcode.cc\"],\n icon: \"eflowcode\",\n iconColor: \"#000000\",\n },\n {\n name: \"OpenRouter\",\n websiteUrl: \"https://openrouter.ai\",\n apiKeyUrl: \"https://openrouter.ai/keys\",\n category: \"aggregator\",\n baseUrl: \"https://openrouter.ai/api\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: mappedRoutes(\n \"anthropic/claude-sonnet-4.6\",\n \"anthropic/claude-opus-4.8\",\n \"anthropic/claude-haiku-4.5\",\n true,\n ),\n icon: \"openrouter\",\n iconColor: \"#6566F1\",\n },\n {\n name: \"TheRouter\",\n websiteUrl: \"https://therouter.ai\",\n apiKeyUrl: \"https://dashboard.therouter.ai\",\n category: \"aggregator\",\n baseUrl: \"https://api.therouter.ai\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: mappedRoutes(\n \"anthropic/claude-sonnet-4.6\",\n \"anthropic/claude-opus-4.8\",\n \"anthropic/claude-haiku-4.5\",\n true,\n ),\n endpointCandidates: [\"https://api.therouter.ai\"],\n },\n {\n name: \"Novita AI\",\n websiteUrl: \"https://novita.ai\",\n apiKeyUrl: \"https://novita.ai\",\n category: \"aggregator\",\n baseUrl: \"https://api.novita.ai/anthropic\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\n \"zai-org/glm-5.1\",\n \"zai-org/glm-5.1\",\n \"zai-org/glm-5.1\",\n ),\n endpointCandidates: [\"https://api.novita.ai/anthropic\"],\n icon: \"novita\",\n iconColor: \"#000000\",\n },\n {\n name: \"LemonData\",\n websiteUrl: \"https://lemondata.cc\",\n apiKeyUrl: \"https://lemondata.cc/r/FFX1ZDUP\",\n category: \"third_party\",\n baseUrl: \"https://api.lemondata.cc\",\n apiKeyField: \"ANTHROPIC_API_KEY\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n isPartner: true,\n partnerPromotionKey: \"lemondata\",\n icon: \"lemondata\",\n },\n {\n name: \"Nvidia\",\n websiteUrl: \"https://build.nvidia.com\",\n apiKeyUrl: \"https://build.nvidia.com/settings/api-keys\",\n category: \"aggregator\",\n baseUrl: \"https://integrate.api.nvidia.com\",\n mode: \"proxy\",\n apiFormat: \"openai_chat\",\n modelRoutes: brandedRoutes(\n \"moonshotai/kimi-k2.5\",\n \"moonshotai/kimi-k2.5\",\n \"moonshotai/kimi-k2.5\",\n ),\n icon: \"nvidia\",\n iconColor: \"#000000\",\n },\n {\n name: \"PIPELLM\",\n websiteUrl: \"https://code.pipellm.ai\",\n apiKeyUrl: \"https://code.pipellm.ai/login?ref=uvw650za\",\n category: \"aggregator\",\n baseUrl: \"https://cc-api.pipellm.ai\",\n mode: \"direct\",\n apiFormat: \"anthropic\",\n modelRoutes: passthroughRoutes(),\n icon: \"pipellm\",\n },\n {\n name: \"Xiaomi MiMo\",\n websiteUrl: \"https://platform.xiaomimimo.com\",\n apiKeyUrl: \"https://platform.xiaomimimo.com/#/console/api-keys\",\n category: \"cn_official\",\n baseUrl: \"https://api.xiaomimimo.com/anthropic\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\n \"mimo-v2.5-pro\",\n \"mimo-v2.5-pro\",\n \"mimo-v2.5-pro\",\n ),\n icon: \"xiaomimimo\",\n iconColor: \"#000000\",\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 category: \"cn_official\",\n baseUrl: \"https://token-plan-cn.xiaomimimo.com/anthropic\",\n mode: \"proxy\",\n apiFormat: \"anthropic\",\n modelRoutes: brandedRoutes(\n \"mimo-v2.5-pro\",\n \"mimo-v2.5-pro\",\n \"mimo-v2.5-pro\",\n ),\n icon: \"xiaomimimo\",\n iconColor: \"#000000\",\n },\n];\n"}}, "src-tauri/src/services/proxy.rs": {"bab9119da3c9dbecaeca84f2a022c9b7ad8e52fe": {"t": 1785746254.5366254, "n": 198198, "h": "9fb1fe7953851da5", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-8\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。\n let takeover_model_fields = if provider.uses_managed_account_auth() {\n Self::build_claude_takeover_model_fields(&provider.settings_config)\n } else {\n Self::build_claude_takeover_model_fields(config)\n };\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy_and_models(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n takeover_model_fields: Vec<(&'static str, String)>,\n ) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n fn claude_provider_with_effective_settings(\n &self,\n provider: &Provider,\n ) -> Result<Provider, String> {\n let mut effective_provider = provider.clone();\n effective_provider.settings_config = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n Ok(effective_provider)\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let effective_provider = self.claude_provider_with_effective_settings(provider)?;\n let mut effective_settings = effective_provider.settings_config.clone();\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n &effective_provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let existing_live = self.read_codex_live().ok();\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?;\n if let Some(existing_live) = existing_live.as_ref() {\n Self::preserve_codex_mcp_servers_from_existing_config(\n &mut effective_settings,\n existing_live,\n )?;\n }\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_takeover_live_for_provider(&effective_settings, Some(provider))?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n pub(crate) async fn lock_switch_for_app(\n &self,\n app_type: &str,\n ) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n let _guard = self.switch_locks.lock_for_app(app_type_str).await;\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n let mut restore_existing_backup_before_takeover = false;\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_matches_current_proxy =\n match self.live_takeover_matches_current_proxy(&app).await {\n Ok(value) => value,\n Err(e) => {\n log::warn!(\"检测 {app_type_str} 接管配置失败(将继续重建接管): {e}\");\n false\n }\n };\n\n // 必须 backup 存在,且 live 确实指向当前代理地址,才算真接管。\n // 只看占位符会把半接管/旧端口残留误判为可复用,导致开启接管后\n // live 文件仍停留在普通供应商配置。\n if has_backup && live_matches_current_proxy {\n return Ok(());\n }\n restore_existing_backup_before_takeover = has_backup;\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_matches_current_proxy={live_matches_current_proxy},正在重新接管并补齐 Live\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n if restore_existing_backup_before_takeover {\n self.restore_live_config_for_app_inner(&app).await?;\n } else {\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app_inner(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback_inner(&app)\n .await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_takeover_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider =\n self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?;\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(&codex_provider),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n Some(&codex_provider),\n );\n\n self.write_codex_takeover_live_for_provider(&live_config, Some(&codex_provider))?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n let provider = self.claude_provider_with_effective_settings(provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ = self.write_codex_takeover_live_for_provider(\n &live_config,\n codex_provider.as_ref(),\n );\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn proxy_urls_match(actual: &str, expected: &str) -> bool {\n actual.trim().trim_end_matches('/') == expected.trim().trim_end_matches('/')\n }\n\n fn codex_config_has_base_url_matching(\n config_text: &str,\n predicate: impl Fn(&str) -> bool,\n ) -> bool {\n let Ok(doc) = toml::from_str::<toml::Value>(config_text) else {\n return false;\n };\n\n let active_provider = doc\n .get(\"model_provider\")\n .and_then(|value| value.as_str())\n .map(str::trim)\n .filter(|id| !id.is_empty());\n\n if let Some(provider_id) = active_provider {\n if doc\n .get(\"model_providers\")\n .and_then(|value| value.get(provider_id))\n .and_then(|value| value.get(\"base_url\"))\n .and_then(|value| value.as_str())\n .is_some_and(&predicate)\n {\n return true;\n }\n }\n\n doc.get(\"base_url\")\n .and_then(|value| value.as_str())\n .is_some_and(predicate)\n }\n\n async fn live_takeover_matches_current_proxy(\n &self,\n app_type: &AppType,\n ) -> Result<bool, String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let config = self.read_claude_live()?;\n let base_url_matches = config\n .get(\"env\")\n .and_then(|value| value.get(\"ANTHROPIC_BASE_URL\"))\n .and_then(|value| value.as_str())\n .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url));\n Ok(Self::is_claude_live_taken_over(&config) && base_url_matches)\n }\n AppType::Codex => {\n let config = self.read_codex_live()?;\n let base_url_matches = config\n .get(\"config\")\n .and_then(|value| value.as_str())\n .is_some_and(|config_text| {\n Self::codex_config_has_base_url_matching(config_text, |url| {\n Self::proxy_urls_match(url, &proxy_codex_base_url)\n })\n });\n Ok(Self::codex_live_has_proxy_placeholder(&config) && base_url_matches)\n }\n AppType::Gemini => {\n let config = self.read_gemini_live()?;\n let base_url_matches = config\n .get(\"env\")\n .and_then(|value| value.get(\"GOOGLE_GEMINI_BASE_URL\"))\n .and_then(|value| value.as_str())\n .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url));\n Ok(Self::is_gemini_live_taken_over(&config) && base_url_matches)\n }\n _ => Ok(false),\n }\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n let updated =\n crate::codex_config::remove_codex_experimental_bearer_token_if(&updated, |token| {\n token == PROXY_TOKEN_PLACEHOLDER\n })\n .map_err(|e| format!(\"清理 Codex 接管占位符失败: {e}\"))?;\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn codex_live_has_proxy_placeholder(config: &Value) -> bool {\n if config\n .get(\"auth\")\n .and_then(|v| v.as_object())\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n return true;\n }\n\n config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .and_then(crate::codex_config::extract_codex_experimental_bearer_token)\n .as_deref()\n == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n Self::codex_live_has_proxy_placeholder(config)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_from_existing_config(\n &mut effective_settings,\n existing_value,\n )?;\n Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.hot_switch_provider_inner(app_type, provider_id).await\n }\n\n pub(crate) async fn hot_switch_provider_inner(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if has_backup && !live_taken_over && matches!(app_type_enum, AppType::Codex) {\n let effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n &provider,\n )\n .map_err(|e| format!(\"构建 Codex 有效配置失败: {e}\"))?;\n let auth = effective_settings\n .get(\"auth\")\n .ok_or_else(|| \"Codex 供应商缺少 auth 配置\".to_string())?;\n let config_str = effective_settings.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n &effective_settings,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_from_existing_config(\n target_settings: &mut Value,\n existing_config: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n fn preserve_codex_oauth_auth_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n if !crate::settings::preserve_codex_official_auth_on_switch() {\n return Ok(());\n }\n\n let Some(existing_auth) = existing_backup\n .get(\"auth\")\n .filter(|auth| crate::codex_config::codex_auth_has_oauth_login_material(auth))\n .cloned()\n else {\n return Ok(());\n };\n\n let Some(target_obj) = target_settings.as_object_mut() else {\n return Ok(());\n };\n\n let provider_auth = target_obj.get(\"auth\").cloned().unwrap_or_else(|| json!({}));\n if let Some(config_text) = target_obj.get(\"config\").and_then(|value| value.as_str()) {\n let live_config = crate::codex_config::prepare_codex_provider_live_config(\n &provider_auth,\n config_text,\n )\n .map_err(|e| format!(\"更新 Codex 备份配置失败: {e}\"))?;\n target_obj.insert(\"config\".to_string(), json!(live_config));\n }\n target_obj.insert(\"auth\".to_string(), existing_auth);\n\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,并按需刷新代理安全的 Live 显示字段)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n crate::codex_config::read_codex_live_settings()\n .map_err(|e| format!(\"读取 Codex Live 配置失败: {e}\"))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n self.write_codex_live_verbatim(config)\n }\n\n fn write_codex_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n let Some(provider) = provider else {\n if crate::settings::preserve_codex_official_auth_on_switch() {\n if let (Some(auth), Some(config_str)) = (\n config.get(\"auth\"),\n config.get(\"config\").and_then(|v| v.as_str()),\n ) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n let live_config = crate::codex_config::prepare_codex_provider_live_config(\n auth, config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n crate::codex_config::write_codex_live_config_atomic(Some(&live_config))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n return Ok(());\n }\n }\n }\n\n return self.write_codex_live_verbatim(config);\n };\n\n let auth = config\n .get(\"auth\")\n .ok_or_else(|| \"Codex 配置缺少 auth 字段\".to_string())?;\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n config,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))\n }\n\n fn codex_auth_has_proxy_placeholder(auth: &Value) -> bool {\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn write_codex_takeover_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n if crate::settings::preserve_codex_official_auth_on_switch() {\n if let Some(auth) = config\n .get(\"auth\")\n .filter(|auth| Self::codex_auth_has_proxy_placeholder(auth))\n {\n let config_str = config.get(\"config\").and_then(|v| v.as_str()).unwrap_or(\"\");\n let prepared_config =\n crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(\n config, config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n let live_config =\n crate::codex_config::prepare_codex_provider_live_config(auth, &prepared_config)\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n crate::codex_config::write_codex_live_config_atomic(Some(&live_config))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n return Ok(());\n }\n }\n\n self.write_codex_live_for_provider(config, provider)\n }\n\n fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Decide the config.toml text ONCE, before splitting on auth. A stored\n // Codex backup comes in two shapes needing opposite handling:\n // - snapshot backup (`read_codex_live_settings`): no inline `modelCatalog`;\n // the config text already carries the live `model_catalog_json` pointer\n // → keep raw, or projection would strip it.\n // - provider-rebuilt backup (`update_live_backup_from_provider`): inline\n // `modelCatalog` (DB SSOT) with a pointer-less config text → project,\n // or the mapping is lost on restore.\n // The projection decision is orthogonal to auth: a provider-rebuilt backup\n // can pair an inline `modelCatalog` with empty/absent `auth.json` (the key\n // living in the config's `experimental_bearer_token`). Computing it up here\n // keeps every config-writing branch — write-auth, delete-auth, no-auth —\n // consistent instead of letting the empty-auth path skip projection.\n let prepared_cfg = config_str\n .map(|cfg| {\n crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(\n config, cfg,\n )\n })\n .transpose()\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n\n match (auth, prepared_cfg.as_deref()) {\n (Some(auth), Some(cfg)) => {\n let auth_path = get_codex_auth_path();\n if auth.as_object().is_some_and(|obj| obj.is_empty()) {\n let _ = crate::config::delete_file(&auth_path);\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n } else {\n crate::codex_config::write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 fn assert_env_str(env: &Map<String, Value>, key: &str, expected: Option<&str>) {\n assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);\n }\n\n fn seed_codex_model_template() {\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n serde_json::to_string(&serde_json::json!({\n \"models\": [{\n \"slug\": \"gpt-5.5\",\n \"display_name\": \"GPT-5.5\",\n \"model_messages\": { \"instructions_template\": \"t\" },\n \"additional_speed_tiers\": [],\n \"context_window\": 128000\n }]\n }))\n .expect(\"serialize models_cache\"),\n )\n .expect(\"write models_cache.json\");\n }\n\n #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_copilot_models_from_provider() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.5\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-sonnet-4.6\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_API_KEY\": \"stale-key\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"claude-haiku-4.5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_codex_models_from_provider() {\n let mut provider = Provider::with_id(\n \"codex\".to_string(),\n \"Codex\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://chatgpt.com/backend-api/codex\",\n \"ANTHROPIC_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"gpt-5.4-mini\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"gpt-5.4\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"codex_oauth\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale-token\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"gpt-5.4-mini\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_preserves_oauth_auth_json() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_settings = json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"third-party Codex proxy writes must not overwrite ChatGPT OAuth login state\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"experimental_bearer_token\"),\n \"proxy placeholder should move into config.toml instead of auth.json\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"live config should carry the proxy placeholder token\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserves_oauth_auth_json_when_preserve_enabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"cn_official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"Codex takeover should not overwrite ChatGPT OAuth auth when preservation is enabled\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"takeover placeholder should move into config.toml\"\n );\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"Codex takeover detection should recognize config.toml placeholders\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserves_oauth_auth_json_even_when_provider_category_is_official() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"Codex takeover must not rewrite auth.json when preservation is enabled, even if provider category is stale or misclassified\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"takeover placeholder should move into config.toml\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_set_takeover_for_app_preserves_oauth_auth_json_when_preserve_enabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"enable Codex takeover\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"the public takeover command path must not rewrite auth.json when preservation is enabled\"\n );\n\n service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_sync_current_to_live_during_takeover_preserves_oauth_auth_json() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"enable Codex takeover\");\n\n crate::services::provider::ProviderService::sync_current_to_live(&state)\n .expect(\"sync current providers while Codex is taken over\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"post-change provider sync must not rewrite Codex auth.json during takeover\"\n );\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let backup_value: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n assert_eq!(\n backup_value.get(\"auth\"),\n Some(&oauth_auth),\n \"provider-derived takeover backup should preserve official OAuth auth\"\n );\n assert!(\n backup_value\n .get(\"config\")\n .and_then(|value| value.as_str())\n .is_some_and(|config| config.contains(\"deepseek-key\")),\n \"provider token should be carried by config.toml in the restore backup\"\n );\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n let restored_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read restored auth\");\n assert_eq!(\n restored_auth, oauth_auth,\n \"turning takeover off should restore the preserved official OAuth auth\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_sync_current_to_live_during_takeover_activation_keeps_proxy_live_config() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n state\n .proxy_service\n .backup_live_config_strict(&AppType::Codex)\n .await\n .expect(\"backup Codex live config\");\n state\n .proxy_service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n assert!(\n !db.get_proxy_config_for_app(\"codex\")\n .await\n .expect(\"get Codex proxy config\")\n .enabled,\n \"this reproduces the activation window before set_takeover_for_app marks enabled=true\"\n );\n\n crate::services::provider::ProviderService::sync_current_to_live(&state)\n .expect(\"sync current providers during takeover activation\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"activation-time provider sync must not rewrite Codex OAuth auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"activation-time provider sync must keep the proxy bearer placeholder\"\n );\n assert!(\n live_config.contains(\"http://127.0.0.1\"),\n \"activation-time provider sync must keep the local proxy base_url\"\n );\n assert!(\n state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"Codex live config should still be detected as taken over\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_set_takeover_rebuilds_stale_enabled_state_without_overwriting_backup() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let original_deepseek_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n let stale_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"PROXY_MANAGED\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(stale_live_config))\n .expect(\"seed stale Codex live config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": oauth_auth,\n \"config\": original_deepseek_config\n }))\n .expect(\"serialize original backup\"),\n )\n .await\n .expect(\"seed original live backup\");\n let mut proxy_config = db\n .get_proxy_config_for_app(\"codex\")\n .await\n .expect(\"get Codex proxy config\");\n proxy_config.enabled = true;\n db.update_proxy_config_for_app(proxy_config)\n .await\n .expect(\"mark Codex takeover enabled\");\n\n service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"rebuild Codex takeover\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"repairing stale takeover must restore the preserved OAuth auth from backup\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"http://127.0.0.1:15721/v1\"),\n \"stale enabled takeover must be rebuilt to the current proxy base_url\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"rebuilt takeover should keep the proxy bearer placeholder\"\n );\n assert!(\n service\n .live_takeover_matches_current_proxy(&AppType::Codex)\n .await\n .expect(\"detect rebuilt Codex takeover\"),\n \"rebuilt Codex live config should match the active proxy address\"\n );\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get Codex live backup\")\n .expect(\"backup exists\");\n let backup_value: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n assert_eq!(\n backup_value.get(\"auth\"),\n Some(&oauth_auth),\n \"rebuilding stale takeover must not overwrite the original OAuth backup\"\n );\n assert!(\n backup_value\n .get(\"config\")\n .and_then(|value| value.as_str())\n .is_some_and(|config| config.contains(\"deepseek-key\")\n && !config.contains(\"http://127.0.0.1\")),\n \"backup should remain the restorable DeepSeek config, not the proxy config\"\n );\n\n service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserve_disabled_uses_legacy_auth_write_path() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"cn_official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth\n .get(\"OPENAI_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER),\n \"disabled preservation should keep the legacy auth.json takeover placeholder\"\n );\n assert_eq!(\n live_auth\n .get(\"tokens\")\n .and_then(|tokens| tokens.get(\"access_token\"))\n .and_then(|value| value.as_str()),\n Some(\"oauth-access\"),\n \"the new config-only takeover branch must not run when preservation is disabled\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"disabled preservation should not move the takeover placeholder into config.toml\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n #[serial]\n fn codex_takeover_cleanup_removes_config_placeholder_without_touching_oauth_auth() {\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 service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\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 ),\n )\n .expect(\"seed taken-over Codex live config\");\n\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"config.toml placeholder should be detected before cleanup\"\n );\n\n service\n .cleanup_codex_takeover_placeholders_in_live()\n .expect(\"cleanup Codex takeover placeholders\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"cleanup should preserve ChatGPT OAuth auth\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"cleanup should remove config.toml proxy bearer placeholder\"\n );\n assert!(\n !live_config.contains(\"http://127.0.0.1:15721\"),\n \"cleanup should remove local proxy base_url\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_auth = json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n let takeover_settings = json!({\n \"auth\": takeover_auth,\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth,\n json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n }),\n \"disabled preservation should let third-party switches overwrite auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(\"experimental_bearer_token\"),\n \"provider token should stay in auth.json when preservation is disabled\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-8[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider-derived restore backup should preserve the provider's model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"custom\").is_none());\n assert_eq!(\n backup_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 \"provider id should point at the hot-switched provider endpoint\"\n );\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"hot-switched Codex live config should expose the selected provider\"\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .and_then(|v| v.get(\"name\"))\n .and_then(|v| v.as_str()),\n Some(\"AiHubMix\"),\n \"Codex app provider label should follow the selected provider\"\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"http://127.0.0.1:15721/v1\"),\n \"taken-over live config should stay pointed at the local proxy\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"restored Codex live config should preserve the provider's model_provider\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_updates_live_provider_display() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"deepseek\")\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"name\"))\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek\")\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"http://127.0.0.1:15721/v1\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_refreshes_catalog_and_common_config() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n db.set_config_snippet(\n \"codex\",\n Some(\n r#\"[mcp_servers.shared]\ncommand = \"shared-command\"\n\"#\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect(\"provider switch to provider b\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n assert!(\n catalog_path.exists(),\n \"cc-switch-model-catalog.json must be created on provider switch\"\n );\n let catalog_text = std::fs::read_to_string(&catalog_path).expect(\"read catalog json\");\n let catalog: serde_json::Value =\n serde_json::from_str(&catalog_text).expect(\"parse catalog json\");\n let slugs: Vec<&str> = catalog\n .get(\"models\")\n .and_then(|m| m.as_array())\n .map(|arr| {\n arr.iter()\n .filter_map(|e| e.get(\"slug\").and_then(|s| s.as_str()))\n .collect()\n })\n .unwrap_or_default();\n assert!(\n slugs.contains(&\"model-b\"),\n \"catalog must contain provider B's model after switch; got: {slugs:?}\"\n );\n assert!(\n !slugs.contains(&\"model-a\"),\n \"catalog must not contain stale provider A model after switch; got: {slugs:?}\"\n );\n\n let config_path = crate::codex_config::get_codex_config_path();\n let config_text = std::fs::read_to_string(&config_path).expect(\"read config.toml\");\n assert!(\n config_text.contains(\"model_catalog_json\"),\n \"config.toml must reference model_catalog_json after switch\"\n );\n assert!(\n config_text.contains(\"[mcp_servers.shared]\"),\n \"config.toml must keep common config after switch\"\n );\n assert!(\n config_text.contains(r#\"command = \"shared-command\"\"#),\n \"config.toml must include common config content after switch\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_propagates_catalog_write_errors() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n if catalog_path.exists() {\n std::fs::remove_file(&catalog_path).expect(\"remove catalog file\");\n }\n std::fs::create_dir_all(&catalog_path).expect(\"turn catalog path into directory\");\n\n let err = crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect_err(\"provider switch should fail when catalog cannot be written\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let message = err.to_string();\n assert!(\n message.contains(\"写入 Codex 配置失败\") || message.contains(\"原子替换失败\"),\n \"switch should surface catalog write failure, got: {message}\"\n );\n }\n\n /// Regression: turning proxy takeover off restores Live from the backup. The\n /// backup snapshot is `read_codex_live_settings()` output (`{auth, config}`,\n /// never an inline `modelCatalog`). The restore must NOT route the config\n /// through catalog projection, which would see no specs and strip the\n /// `model_catalog_json` pointer — silently dropping the user's Codex model\n /// mapping from Live even though the DB SSOT still holds it.\n #[tokio::test]\n #[serial]\n async fn codex_restore_from_backup_preserves_model_catalog_pointer() {\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 service = ProxyService::new(db.clone());\n\n // Pre-takeover Live state: config.toml points at the cc-switch generated\n // catalog file, and that file exists on disk (takeover never touches it).\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n if let Some(parent) = catalog_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create codex dir\");\n }\n std::fs::write(\n &catalog_path,\n r#\"{\"models\":[{\"slug\":\"deepseek-v4-flash\"}]}\"#,\n )\n .expect(\"seed generated catalog file\");\n\n let pointer = catalog_path.to_string_lossy().to_string();\n let backup_config = format!(\n \"model_provider = \\\"custom\\\"\\n\\\n model = \\\"deepseek-v4-flash\\\"\\n\\\n model_catalog_json = \\\"{pointer}\\\"\\n\\n\\\n [model_providers.custom]\\n\\\n name = \\\"DeepSeek\\\"\\n\\\n base_url = \\\"https://api.deepseek.example/v1\\\"\\n\\\n wire_api = \\\"responses\\\"\\n\"\n );\n let backup_json = serde_json::to_string(&json!({\n \"auth\": { \"OPENAI_API_KEY\": \"deepseek-key\" },\n \"config\": backup_config,\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n // Turning takeover off restores Live from this backup.\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"restore must preserve the model_catalog_json pointer, got:\\n{restored}\"\n );\n assert!(\n restored.contains(pointer.as_str()),\n \"restored pointer must still reference the cc-switch generated catalog file\"\n );\n }\n\n /// Regression: a hot-switch during takeover rebuilds the backup from the DB\n /// provider (`update_live_backup_from_provider`), so the backup carries an\n /// inline `modelCatalog` (DB SSOT) but a `config.toml` text WITHOUT a\n /// `model_catalog_json` pointer. Restoring that backup must project the\n /// inline catalog — (re)generating both the catalog file and the pointer —\n /// or the Codex model mapping vanishes from Live after takeover-off.\n #[tokio::test]\n #[serial]\n async fn codex_restore_from_backup_projects_inline_model_catalog() {\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 service = ProxyService::new(db.clone());\n\n // Catalog projection needs a model template; seed `models_cache.json`\n // with the template slug so we don't depend on the `codex` CLI.\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n r#\"{\"models\":[{\"slug\":\"gpt-5.5\"}]}\"#,\n )\n .expect(\"seed models_cache template\");\n\n // Provider-rebuilt backup shape: inline modelCatalog, pointer-less config.\n let backup_json = serde_json::to_string(&json!({\n \"auth\": { \"OPENAI_API_KEY\": \"deepseek-key\" },\n \"config\": \"model_provider = \\\"custom\\\"\\nmodel = \\\"deepseek-v4-flash\\\"\\n\\n[model_providers.custom]\\nname = \\\"DeepSeek\\\"\\nbase_url = \\\"https://api.deepseek.example/v1\\\"\\nwire_api = \\\"responses\\\"\\n\",\n \"modelCatalog\": {\n \"models\": [\n { \"model\": \"deepseek-v4-flash\", \"displayName\": \"DeepSeek V4 Flash\", \"contextWindow\": 1_000_000 }\n ]\n }\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"restore must (re)generate the model_catalog_json pointer from inline catalog, got:\\n{restored}\"\n );\n assert!(\n catalog_path.exists(),\n \"restore must generate the cc-switch catalog file on disk\"\n );\n let catalog: Value = serde_json::from_str(\n &std::fs::read_to_string(&catalog_path).expect(\"read generated catalog\"),\n )\n .expect(\"parse generated catalog\");\n let slugs: Vec<&str> = catalog\n .get(\"models\")\n .and_then(|m| m.as_array())\n .expect(\"catalog models\")\n .iter()\n .filter_map(|m| m.get(\"slug\").and_then(|s| s.as_str()))\n .collect();\n assert!(\n slugs.contains(&\"deepseek-v4-flash\"),\n \"generated catalog must contain the inline model, got slugs: {slugs:?}\"\n );\n }\n\n /// Regression: a provider-rebuilt backup can pair an inline `modelCatalog`\n /// with EMPTY `auth.json` (`{}`) — the bearer-token / Mobile-compat shape\n /// where the API key lives in the config's `experimental_bearer_token`. The\n /// empty-auth restore branch deletes `auth.json` and writes config raw; it\n /// must still project the inline catalog (decision is orthogonal to auth), or\n /// the model mapping vanishes on takeover-off for this provider shape.\n #[tokio::test]\n #[serial]\n async fn codex_restore_empty_auth_backup_still_projects_inline_catalog() {\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 service = ProxyService::new(db.clone());\n\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n r#\"{\"models\":[{\"slug\":\"gpt-5.5\"}]}\"#,\n )\n .expect(\"seed models_cache template\");\n\n // Empty auth.json + key carried in config.toml's experimental_bearer_token,\n // plus the inline modelCatalog (DB SSOT).\n let backup_json = serde_json::to_string(&json!({\n \"auth\": {},\n \"config\": \"model_provider = \\\"custom\\\"\\nmodel = \\\"deepseek-v4-flash\\\"\\n\\n[model_providers.custom]\\nname = \\\"DeepSeek\\\"\\nbase_url = \\\"https://api.deepseek.example/v1\\\"\\nwire_api = \\\"responses\\\"\\nexperimental_bearer_token = \\\"sk-deepseek\\\"\\n\",\n \"modelCatalog\": {\n \"models\": [ { \"model\": \"deepseek-v4-flash\", \"displayName\": \"DeepSeek V4 Flash\" } ]\n }\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"empty-auth restore must still project the inline catalog pointer, got:\\n{restored}\"\n );\n assert!(\n crate::codex_config::get_codex_model_catalog_path().exists(),\n \"empty-auth restore must generate the cc-switch catalog file\"\n );\n assert!(\n !crate::codex_config::get_codex_auth_path().exists(),\n \"empty-auth restore must delete auth.json rather than write an empty one\"\n );\n }\n}\n"}, "757340f159b9c40c22745fdfb588f3c51298d2d1": {"t": 1785761114.8195894, "n": 198198, "h": "9fb1fe7953851da5", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-8\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。\n let takeover_model_fields = if provider.uses_managed_account_auth() {\n Self::build_claude_takeover_model_fields(&provider.settings_config)\n } else {\n Self::build_claude_takeover_model_fields(config)\n };\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy_and_models(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n takeover_model_fields: Vec<(&'static str, String)>,\n ) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n fn claude_provider_with_effective_settings(\n &self,\n provider: &Provider,\n ) -> Result<Provider, String> {\n let mut effective_provider = provider.clone();\n effective_provider.settings_config = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n Ok(effective_provider)\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let effective_provider = self.claude_provider_with_effective_settings(provider)?;\n let mut effective_settings = effective_provider.settings_config.clone();\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n &effective_provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let existing_live = self.read_codex_live().ok();\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?;\n if let Some(existing_live) = existing_live.as_ref() {\n Self::preserve_codex_mcp_servers_from_existing_config(\n &mut effective_settings,\n existing_live,\n )?;\n }\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_takeover_live_for_provider(&effective_settings, Some(provider))?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n pub(crate) async fn lock_switch_for_app(\n &self,\n app_type: &str,\n ) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n let _guard = self.switch_locks.lock_for_app(app_type_str).await;\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n let mut restore_existing_backup_before_takeover = false;\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_matches_current_proxy =\n match self.live_takeover_matches_current_proxy(&app).await {\n Ok(value) => value,\n Err(e) => {\n log::warn!(\"检测 {app_type_str} 接管配置失败(将继续重建接管): {e}\");\n false\n }\n };\n\n // 必须 backup 存在,且 live 确实指向当前代理地址,才算真接管。\n // 只看占位符会把半接管/旧端口残留误判为可复用,导致开启接管后\n // live 文件仍停留在普通供应商配置。\n if has_backup && live_matches_current_proxy {\n return Ok(());\n }\n restore_existing_backup_before_takeover = has_backup;\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_matches_current_proxy={live_matches_current_proxy},正在重新接管并补齐 Live\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n if restore_existing_backup_before_takeover {\n self.restore_live_config_for_app_inner(&app).await?;\n } else {\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app_inner(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback_inner(&app)\n .await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_takeover_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider =\n self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?;\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(&codex_provider),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n Some(&codex_provider),\n );\n\n self.write_codex_takeover_live_for_provider(&live_config, Some(&codex_provider))?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n let provider = self.claude_provider_with_effective_settings(provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ = self.write_codex_takeover_live_for_provider(\n &live_config,\n codex_provider.as_ref(),\n );\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn proxy_urls_match(actual: &str, expected: &str) -> bool {\n actual.trim().trim_end_matches('/') == expected.trim().trim_end_matches('/')\n }\n\n fn codex_config_has_base_url_matching(\n config_text: &str,\n predicate: impl Fn(&str) -> bool,\n ) -> bool {\n let Ok(doc) = toml::from_str::<toml::Value>(config_text) else {\n return false;\n };\n\n let active_provider = doc\n .get(\"model_provider\")\n .and_then(|value| value.as_str())\n .map(str::trim)\n .filter(|id| !id.is_empty());\n\n if let Some(provider_id) = active_provider {\n if doc\n .get(\"model_providers\")\n .and_then(|value| value.get(provider_id))\n .and_then(|value| value.get(\"base_url\"))\n .and_then(|value| value.as_str())\n .is_some_and(&predicate)\n {\n return true;\n }\n }\n\n doc.get(\"base_url\")\n .and_then(|value| value.as_str())\n .is_some_and(predicate)\n }\n\n async fn live_takeover_matches_current_proxy(\n &self,\n app_type: &AppType,\n ) -> Result<bool, String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let config = self.read_claude_live()?;\n let base_url_matches = config\n .get(\"env\")\n .and_then(|value| value.get(\"ANTHROPIC_BASE_URL\"))\n .and_then(|value| value.as_str())\n .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url));\n Ok(Self::is_claude_live_taken_over(&config) && base_url_matches)\n }\n AppType::Codex => {\n let config = self.read_codex_live()?;\n let base_url_matches = config\n .get(\"config\")\n .and_then(|value| value.as_str())\n .is_some_and(|config_text| {\n Self::codex_config_has_base_url_matching(config_text, |url| {\n Self::proxy_urls_match(url, &proxy_codex_base_url)\n })\n });\n Ok(Self::codex_live_has_proxy_placeholder(&config) && base_url_matches)\n }\n AppType::Gemini => {\n let config = self.read_gemini_live()?;\n let base_url_matches = config\n .get(\"env\")\n .and_then(|value| value.get(\"GOOGLE_GEMINI_BASE_URL\"))\n .and_then(|value| value.as_str())\n .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url));\n Ok(Self::is_gemini_live_taken_over(&config) && base_url_matches)\n }\n _ => Ok(false),\n }\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n let updated =\n crate::codex_config::remove_codex_experimental_bearer_token_if(&updated, |token| {\n token == PROXY_TOKEN_PLACEHOLDER\n })\n .map_err(|e| format!(\"清理 Codex 接管占位符失败: {e}\"))?;\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn codex_live_has_proxy_placeholder(config: &Value) -> bool {\n if config\n .get(\"auth\")\n .and_then(|v| v.as_object())\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n return true;\n }\n\n config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .and_then(crate::codex_config::extract_codex_experimental_bearer_token)\n .as_deref()\n == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n Self::codex_live_has_proxy_placeholder(config)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_from_existing_config(\n &mut effective_settings,\n existing_value,\n )?;\n Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.hot_switch_provider_inner(app_type, provider_id).await\n }\n\n pub(crate) async fn hot_switch_provider_inner(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if has_backup && !live_taken_over && matches!(app_type_enum, AppType::Codex) {\n let effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n &provider,\n )\n .map_err(|e| format!(\"构建 Codex 有效配置失败: {e}\"))?;\n let auth = effective_settings\n .get(\"auth\")\n .ok_or_else(|| \"Codex 供应商缺少 auth 配置\".to_string())?;\n let config_str = effective_settings.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n &effective_settings,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_from_existing_config(\n target_settings: &mut Value,\n existing_config: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n fn preserve_codex_oauth_auth_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n if !crate::settings::preserve_codex_official_auth_on_switch() {\n return Ok(());\n }\n\n let Some(existing_auth) = existing_backup\n .get(\"auth\")\n .filter(|auth| crate::codex_config::codex_auth_has_oauth_login_material(auth))\n .cloned()\n else {\n return Ok(());\n };\n\n let Some(target_obj) = target_settings.as_object_mut() else {\n return Ok(());\n };\n\n let provider_auth = target_obj.get(\"auth\").cloned().unwrap_or_else(|| json!({}));\n if let Some(config_text) = target_obj.get(\"config\").and_then(|value| value.as_str()) {\n let live_config = crate::codex_config::prepare_codex_provider_live_config(\n &provider_auth,\n config_text,\n )\n .map_err(|e| format!(\"更新 Codex 备份配置失败: {e}\"))?;\n target_obj.insert(\"config\".to_string(), json!(live_config));\n }\n target_obj.insert(\"auth\".to_string(), existing_auth);\n\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,并按需刷新代理安全的 Live 显示字段)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n crate::codex_config::read_codex_live_settings()\n .map_err(|e| format!(\"读取 Codex Live 配置失败: {e}\"))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n self.write_codex_live_verbatim(config)\n }\n\n fn write_codex_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n let Some(provider) = provider else {\n if crate::settings::preserve_codex_official_auth_on_switch() {\n if let (Some(auth), Some(config_str)) = (\n config.get(\"auth\"),\n config.get(\"config\").and_then(|v| v.as_str()),\n ) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n let live_config = crate::codex_config::prepare_codex_provider_live_config(\n auth, config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n crate::codex_config::write_codex_live_config_atomic(Some(&live_config))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n return Ok(());\n }\n }\n }\n\n return self.write_codex_live_verbatim(config);\n };\n\n let auth = config\n .get(\"auth\")\n .ok_or_else(|| \"Codex 配置缺少 auth 字段\".to_string())?;\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n config,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))\n }\n\n fn codex_auth_has_proxy_placeholder(auth: &Value) -> bool {\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn write_codex_takeover_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n if crate::settings::preserve_codex_official_auth_on_switch() {\n if let Some(auth) = config\n .get(\"auth\")\n .filter(|auth| Self::codex_auth_has_proxy_placeholder(auth))\n {\n let config_str = config.get(\"config\").and_then(|v| v.as_str()).unwrap_or(\"\");\n let prepared_config =\n crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(\n config, config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n let live_config =\n crate::codex_config::prepare_codex_provider_live_config(auth, &prepared_config)\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n crate::codex_config::write_codex_live_config_atomic(Some(&live_config))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n return Ok(());\n }\n }\n\n self.write_codex_live_for_provider(config, provider)\n }\n\n fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Decide the config.toml text ONCE, before splitting on auth. A stored\n // Codex backup comes in two shapes needing opposite handling:\n // - snapshot backup (`read_codex_live_settings`): no inline `modelCatalog`;\n // the config text already carries the live `model_catalog_json` pointer\n // → keep raw, or projection would strip it.\n // - provider-rebuilt backup (`update_live_backup_from_provider`): inline\n // `modelCatalog` (DB SSOT) with a pointer-less config text → project,\n // or the mapping is lost on restore.\n // The projection decision is orthogonal to auth: a provider-rebuilt backup\n // can pair an inline `modelCatalog` with empty/absent `auth.json` (the key\n // living in the config's `experimental_bearer_token`). Computing it up here\n // keeps every config-writing branch — write-auth, delete-auth, no-auth —\n // consistent instead of letting the empty-auth path skip projection.\n let prepared_cfg = config_str\n .map(|cfg| {\n crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(\n config, cfg,\n )\n })\n .transpose()\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n\n match (auth, prepared_cfg.as_deref()) {\n (Some(auth), Some(cfg)) => {\n let auth_path = get_codex_auth_path();\n if auth.as_object().is_some_and(|obj| obj.is_empty()) {\n let _ = crate::config::delete_file(&auth_path);\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n } else {\n crate::codex_config::write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 fn assert_env_str(env: &Map<String, Value>, key: &str, expected: Option<&str>) {\n assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);\n }\n\n fn seed_codex_model_template() {\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n serde_json::to_string(&serde_json::json!({\n \"models\": [{\n \"slug\": \"gpt-5.5\",\n \"display_name\": \"GPT-5.5\",\n \"model_messages\": { \"instructions_template\": \"t\" },\n \"additional_speed_tiers\": [],\n \"context_window\": 128000\n }]\n }))\n .expect(\"serialize models_cache\"),\n )\n .expect(\"write models_cache.json\");\n }\n\n #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_copilot_models_from_provider() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.5\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-sonnet-4.6\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_API_KEY\": \"stale-key\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"claude-haiku-4.5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_codex_models_from_provider() {\n let mut provider = Provider::with_id(\n \"codex\".to_string(),\n \"Codex\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://chatgpt.com/backend-api/codex\",\n \"ANTHROPIC_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"gpt-5.4-mini\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"gpt-5.4\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"codex_oauth\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale-token\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"gpt-5.4-mini\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_preserves_oauth_auth_json() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_settings = json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"third-party Codex proxy writes must not overwrite ChatGPT OAuth login state\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"experimental_bearer_token\"),\n \"proxy placeholder should move into config.toml instead of auth.json\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"live config should carry the proxy placeholder token\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserves_oauth_auth_json_when_preserve_enabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"cn_official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"Codex takeover should not overwrite ChatGPT OAuth auth when preservation is enabled\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"takeover placeholder should move into config.toml\"\n );\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"Codex takeover detection should recognize config.toml placeholders\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserves_oauth_auth_json_even_when_provider_category_is_official() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"Codex takeover must not rewrite auth.json when preservation is enabled, even if provider category is stale or misclassified\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"takeover placeholder should move into config.toml\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_set_takeover_for_app_preserves_oauth_auth_json_when_preserve_enabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"enable Codex takeover\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"the public takeover command path must not rewrite auth.json when preservation is enabled\"\n );\n\n service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_sync_current_to_live_during_takeover_preserves_oauth_auth_json() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"enable Codex takeover\");\n\n crate::services::provider::ProviderService::sync_current_to_live(&state)\n .expect(\"sync current providers while Codex is taken over\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"post-change provider sync must not rewrite Codex auth.json during takeover\"\n );\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let backup_value: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n assert_eq!(\n backup_value.get(\"auth\"),\n Some(&oauth_auth),\n \"provider-derived takeover backup should preserve official OAuth auth\"\n );\n assert!(\n backup_value\n .get(\"config\")\n .and_then(|value| value.as_str())\n .is_some_and(|config| config.contains(\"deepseek-key\")),\n \"provider token should be carried by config.toml in the restore backup\"\n );\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n let restored_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read restored auth\");\n assert_eq!(\n restored_auth, oauth_auth,\n \"turning takeover off should restore the preserved official OAuth auth\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_sync_current_to_live_during_takeover_activation_keeps_proxy_live_config() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n state\n .proxy_service\n .backup_live_config_strict(&AppType::Codex)\n .await\n .expect(\"backup Codex live config\");\n state\n .proxy_service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n assert!(\n !db.get_proxy_config_for_app(\"codex\")\n .await\n .expect(\"get Codex proxy config\")\n .enabled,\n \"this reproduces the activation window before set_takeover_for_app marks enabled=true\"\n );\n\n crate::services::provider::ProviderService::sync_current_to_live(&state)\n .expect(\"sync current providers during takeover activation\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"activation-time provider sync must not rewrite Codex OAuth auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"activation-time provider sync must keep the proxy bearer placeholder\"\n );\n assert!(\n live_config.contains(\"http://127.0.0.1\"),\n \"activation-time provider sync must keep the local proxy base_url\"\n );\n assert!(\n state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"Codex live config should still be detected as taken over\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_set_takeover_rebuilds_stale_enabled_state_without_overwriting_backup() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let original_deepseek_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n let stale_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"PROXY_MANAGED\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(stale_live_config))\n .expect(\"seed stale Codex live config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": oauth_auth,\n \"config\": original_deepseek_config\n }))\n .expect(\"serialize original backup\"),\n )\n .await\n .expect(\"seed original live backup\");\n let mut proxy_config = db\n .get_proxy_config_for_app(\"codex\")\n .await\n .expect(\"get Codex proxy config\");\n proxy_config.enabled = true;\n db.update_proxy_config_for_app(proxy_config)\n .await\n .expect(\"mark Codex takeover enabled\");\n\n service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"rebuild Codex takeover\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"repairing stale takeover must restore the preserved OAuth auth from backup\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"http://127.0.0.1:15721/v1\"),\n \"stale enabled takeover must be rebuilt to the current proxy base_url\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"rebuilt takeover should keep the proxy bearer placeholder\"\n );\n assert!(\n service\n .live_takeover_matches_current_proxy(&AppType::Codex)\n .await\n .expect(\"detect rebuilt Codex takeover\"),\n \"rebuilt Codex live config should match the active proxy address\"\n );\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get Codex live backup\")\n .expect(\"backup exists\");\n let backup_value: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n assert_eq!(\n backup_value.get(\"auth\"),\n Some(&oauth_auth),\n \"rebuilding stale takeover must not overwrite the original OAuth backup\"\n );\n assert!(\n backup_value\n .get(\"config\")\n .and_then(|value| value.as_str())\n .is_some_and(|config| config.contains(\"deepseek-key\")\n && !config.contains(\"http://127.0.0.1\")),\n \"backup should remain the restorable DeepSeek config, not the proxy config\"\n );\n\n service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserve_disabled_uses_legacy_auth_write_path() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"cn_official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth\n .get(\"OPENAI_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER),\n \"disabled preservation should keep the legacy auth.json takeover placeholder\"\n );\n assert_eq!(\n live_auth\n .get(\"tokens\")\n .and_then(|tokens| tokens.get(\"access_token\"))\n .and_then(|value| value.as_str()),\n Some(\"oauth-access\"),\n \"the new config-only takeover branch must not run when preservation is disabled\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"disabled preservation should not move the takeover placeholder into config.toml\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n #[serial]\n fn codex_takeover_cleanup_removes_config_placeholder_without_touching_oauth_auth() {\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 service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\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 ),\n )\n .expect(\"seed taken-over Codex live config\");\n\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"config.toml placeholder should be detected before cleanup\"\n );\n\n service\n .cleanup_codex_takeover_placeholders_in_live()\n .expect(\"cleanup Codex takeover placeholders\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"cleanup should preserve ChatGPT OAuth auth\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"cleanup should remove config.toml proxy bearer placeholder\"\n );\n assert!(\n !live_config.contains(\"http://127.0.0.1:15721\"),\n \"cleanup should remove local proxy base_url\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_auth = json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n let takeover_settings = json!({\n \"auth\": takeover_auth,\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth,\n json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n }),\n \"disabled preservation should let third-party switches overwrite auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(\"experimental_bearer_token\"),\n \"provider token should stay in auth.json when preservation is disabled\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-8[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider-derived restore backup should preserve the provider's model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"custom\").is_none());\n assert_eq!(\n backup_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 \"provider id should point at the hot-switched provider endpoint\"\n );\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"hot-switched Codex live config should expose the selected provider\"\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .and_then(|v| v.get(\"name\"))\n .and_then(|v| v.as_str()),\n Some(\"AiHubMix\"),\n \"Codex app provider label should follow the selected provider\"\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"http://127.0.0.1:15721/v1\"),\n \"taken-over live config should stay pointed at the local proxy\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"restored Codex live config should preserve the provider's model_provider\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_updates_live_provider_display() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"deepseek\")\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"name\"))\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek\")\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"http://127.0.0.1:15721/v1\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_refreshes_catalog_and_common_config() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n db.set_config_snippet(\n \"codex\",\n Some(\n r#\"[mcp_servers.shared]\ncommand = \"shared-command\"\n\"#\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect(\"provider switch to provider b\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n assert!(\n catalog_path.exists(),\n \"cc-switch-model-catalog.json must be created on provider switch\"\n );\n let catalog_text = std::fs::read_to_string(&catalog_path).expect(\"read catalog json\");\n let catalog: serde_json::Value =\n serde_json::from_str(&catalog_text).expect(\"parse catalog json\");\n let slugs: Vec<&str> = catalog\n .get(\"models\")\n .and_then(|m| m.as_array())\n .map(|arr| {\n arr.iter()\n .filter_map(|e| e.get(\"slug\").and_then(|s| s.as_str()))\n .collect()\n })\n .unwrap_or_default();\n assert!(\n slugs.contains(&\"model-b\"),\n \"catalog must contain provider B's model after switch; got: {slugs:?}\"\n );\n assert!(\n !slugs.contains(&\"model-a\"),\n \"catalog must not contain stale provider A model after switch; got: {slugs:?}\"\n );\n\n let config_path = crate::codex_config::get_codex_config_path();\n let config_text = std::fs::read_to_string(&config_path).expect(\"read config.toml\");\n assert!(\n config_text.contains(\"model_catalog_json\"),\n \"config.toml must reference model_catalog_json after switch\"\n );\n assert!(\n config_text.contains(\"[mcp_servers.shared]\"),\n \"config.toml must keep common config after switch\"\n );\n assert!(\n config_text.contains(r#\"command = \"shared-command\"\"#),\n \"config.toml must include common config content after switch\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_propagates_catalog_write_errors() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n if catalog_path.exists() {\n std::fs::remove_file(&catalog_path).expect(\"remove catalog file\");\n }\n std::fs::create_dir_all(&catalog_path).expect(\"turn catalog path into directory\");\n\n let err = crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect_err(\"provider switch should fail when catalog cannot be written\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let message = err.to_string();\n assert!(\n message.contains(\"写入 Codex 配置失败\") || message.contains(\"原子替换失败\"),\n \"switch should surface catalog write failure, got: {message}\"\n );\n }\n\n /// Regression: turning proxy takeover off restores Live from the backup. The\n /// backup snapshot is `read_codex_live_settings()` output (`{auth, config}`,\n /// never an inline `modelCatalog`). The restore must NOT route the config\n /// through catalog projection, which would see no specs and strip the\n /// `model_catalog_json` pointer — silently dropping the user's Codex model\n /// mapping from Live even though the DB SSOT still holds it.\n #[tokio::test]\n #[serial]\n async fn codex_restore_from_backup_preserves_model_catalog_pointer() {\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 service = ProxyService::new(db.clone());\n\n // Pre-takeover Live state: config.toml points at the cc-switch generated\n // catalog file, and that file exists on disk (takeover never touches it).\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n if let Some(parent) = catalog_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create codex dir\");\n }\n std::fs::write(\n &catalog_path,\n r#\"{\"models\":[{\"slug\":\"deepseek-v4-flash\"}]}\"#,\n )\n .expect(\"seed generated catalog file\");\n\n let pointer = catalog_path.to_string_lossy().to_string();\n let backup_config = format!(\n \"model_provider = \\\"custom\\\"\\n\\\n model = \\\"deepseek-v4-flash\\\"\\n\\\n model_catalog_json = \\\"{pointer}\\\"\\n\\n\\\n [model_providers.custom]\\n\\\n name = \\\"DeepSeek\\\"\\n\\\n base_url = \\\"https://api.deepseek.example/v1\\\"\\n\\\n wire_api = \\\"responses\\\"\\n\"\n );\n let backup_json = serde_json::to_string(&json!({\n \"auth\": { \"OPENAI_API_KEY\": \"deepseek-key\" },\n \"config\": backup_config,\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n // Turning takeover off restores Live from this backup.\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"restore must preserve the model_catalog_json pointer, got:\\n{restored}\"\n );\n assert!(\n restored.contains(pointer.as_str()),\n \"restored pointer must still reference the cc-switch generated catalog file\"\n );\n }\n\n /// Regression: a hot-switch during takeover rebuilds the backup from the DB\n /// provider (`update_live_backup_from_provider`), so the backup carries an\n /// inline `modelCatalog` (DB SSOT) but a `config.toml` text WITHOUT a\n /// `model_catalog_json` pointer. Restoring that backup must project the\n /// inline catalog — (re)generating both the catalog file and the pointer —\n /// or the Codex model mapping vanishes from Live after takeover-off.\n #[tokio::test]\n #[serial]\n async fn codex_restore_from_backup_projects_inline_model_catalog() {\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 service = ProxyService::new(db.clone());\n\n // Catalog projection needs a model template; seed `models_cache.json`\n // with the template slug so we don't depend on the `codex` CLI.\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n r#\"{\"models\":[{\"slug\":\"gpt-5.5\"}]}\"#,\n )\n .expect(\"seed models_cache template\");\n\n // Provider-rebuilt backup shape: inline modelCatalog, pointer-less config.\n let backup_json = serde_json::to_string(&json!({\n \"auth\": { \"OPENAI_API_KEY\": \"deepseek-key\" },\n \"config\": \"model_provider = \\\"custom\\\"\\nmodel = \\\"deepseek-v4-flash\\\"\\n\\n[model_providers.custom]\\nname = \\\"DeepSeek\\\"\\nbase_url = \\\"https://api.deepseek.example/v1\\\"\\nwire_api = \\\"responses\\\"\\n\",\n \"modelCatalog\": {\n \"models\": [\n { \"model\": \"deepseek-v4-flash\", \"displayName\": \"DeepSeek V4 Flash\", \"contextWindow\": 1_000_000 }\n ]\n }\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"restore must (re)generate the model_catalog_json pointer from inline catalog, got:\\n{restored}\"\n );\n assert!(\n catalog_path.exists(),\n \"restore must generate the cc-switch catalog file on disk\"\n );\n let catalog: Value = serde_json::from_str(\n &std::fs::read_to_string(&catalog_path).expect(\"read generated catalog\"),\n )\n .expect(\"parse generated catalog\");\n let slugs: Vec<&str> = catalog\n .get(\"models\")\n .and_then(|m| m.as_array())\n .expect(\"catalog models\")\n .iter()\n .filter_map(|m| m.get(\"slug\").and_then(|s| s.as_str()))\n .collect();\n assert!(\n slugs.contains(&\"deepseek-v4-flash\"),\n \"generated catalog must contain the inline model, got slugs: {slugs:?}\"\n );\n }\n\n /// Regression: a provider-rebuilt backup can pair an inline `modelCatalog`\n /// with EMPTY `auth.json` (`{}`) — the bearer-token / Mobile-compat shape\n /// where the API key lives in the config's `experimental_bearer_token`. The\n /// empty-auth restore branch deletes `auth.json` and writes config raw; it\n /// must still project the inline catalog (decision is orthogonal to auth), or\n /// the model mapping vanishes on takeover-off for this provider shape.\n #[tokio::test]\n #[serial]\n async fn codex_restore_empty_auth_backup_still_projects_inline_catalog() {\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 service = ProxyService::new(db.clone());\n\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n r#\"{\"models\":[{\"slug\":\"gpt-5.5\"}]}\"#,\n )\n .expect(\"seed models_cache template\");\n\n // Empty auth.json + key carried in config.toml's experimental_bearer_token,\n // plus the inline modelCatalog (DB SSOT).\n let backup_json = serde_json::to_string(&json!({\n \"auth\": {},\n \"config\": \"model_provider = \\\"custom\\\"\\nmodel = \\\"deepseek-v4-flash\\\"\\n\\n[model_providers.custom]\\nname = \\\"DeepSeek\\\"\\nbase_url = \\\"https://api.deepseek.example/v1\\\"\\nwire_api = \\\"responses\\\"\\nexperimental_bearer_token = \\\"sk-deepseek\\\"\\n\",\n \"modelCatalog\": {\n \"models\": [ { \"model\": \"deepseek-v4-flash\", \"displayName\": \"DeepSeek V4 Flash\" } ]\n }\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"empty-auth restore must still project the inline catalog pointer, got:\\n{restored}\"\n );\n assert!(\n crate::codex_config::get_codex_model_catalog_path().exists(),\n \"empty-auth restore must generate the cc-switch catalog file\"\n );\n assert!(\n !crate::codex_config::get_codex_auth_path().exists(),\n \"empty-auth restore must delete auth.json rather than write an empty one\"\n );\n }\n}\n"}, "dafdac33904f3d2ac4960745356a1f0d9cf4ca3a": {"t": 1785761116.8838649, "n": 203260, "h": "9b1212ad663ba848", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\n<<<<<<< HEAD\nuse serde_json::{json, Map, Value};\n=======\nuse serde_json::{json, Value};\n>>>>>>> origin/cc-switch-cli\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-8\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n<<<<<<< HEAD\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n=======\n /// 清理接管模式下 Claude Live 配置中的模型覆盖字段。\n ///\n /// 这可以避免\"接管开启后切换供应商仍使用旧模型\"的问题。\n /// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。\n pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n>>>>>>> origin/cc-switch-cli\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。\n let takeover_model_fields = if provider.uses_managed_account_auth() {\n Self::build_claude_takeover_model_fields(&provider.settings_config)\n } else {\n Self::build_claude_takeover_model_fields(config)\n };\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy_and_models(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n takeover_model_fields: Vec<(&'static str, String)>,\n ) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n fn claude_provider_with_effective_settings(\n &self,\n provider: &Provider,\n ) -> Result<Provider, String> {\n let mut effective_provider = provider.clone();\n effective_provider.settings_config = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n Ok(effective_provider)\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let effective_provider = self.claude_provider_with_effective_settings(provider)?;\n let mut effective_settings = effective_provider.settings_config.clone();\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n &effective_provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let existing_live = self.read_codex_live().ok();\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?;\n if let Some(existing_live) = existing_live.as_ref() {\n Self::preserve_codex_mcp_servers_from_existing_config(\n &mut effective_settings,\n existing_live,\n )?;\n }\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_takeover_live_for_provider(&effective_settings, Some(provider))?;\n Ok(())\n }\n\n<<<<<<< HEAD\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n=======\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url);\n self.write_claude_live(&effective_settings)?;\n Ok(())\n>>>>>>> origin/cc-switch-cli\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n pub(crate) async fn lock_switch_for_app(\n &self,\n app_type: &str,\n ) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n let _guard = self.switch_locks.lock_for_app(app_type_str).await;\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n let mut restore_existing_backup_before_takeover = false;\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_matches_current_proxy =\n match self.live_takeover_matches_current_proxy(&app).await {\n Ok(value) => value,\n Err(e) => {\n log::warn!(\"检测 {app_type_str} 接管配置失败(将继续重建接管): {e}\");\n false\n }\n };\n\n // 必须 backup 存在,且 live 确实指向当前代理地址,才算真接管。\n // 只看占位符会把半接管/旧端口残留误判为可复用,导致开启接管后\n // live 文件仍停留在普通供应商配置。\n if has_backup && live_matches_current_proxy {\n return Ok(());\n }\n restore_existing_backup_before_takeover = has_backup;\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_matches_current_proxy={live_matches_current_proxy},正在重新接管并补齐 Live\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n if restore_existing_backup_before_takeover {\n self.restore_live_config_for_app_inner(&app).await?;\n } else {\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app_inner(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback_inner(&app)\n .await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n<<<<<<< HEAD\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n=======\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n>>>>>>> origin/cc-switch-cli\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_takeover_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n<<<<<<< HEAD\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider =\n self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n=======\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n>>>>>>> origin/cc-switch-cli\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?;\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(&codex_provider),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n Some(&codex_provider),\n );\n\n self.write_codex_takeover_live_for_provider(&live_config, Some(&codex_provider))?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n<<<<<<< HEAD\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n let provider = self.claude_provider_with_effective_settings(provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n=======\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n>>>>>>> origin/cc-switch-cli\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ = self.write_codex_takeover_live_for_provider(\n &live_config,\n codex_provider.as_ref(),\n );\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n<<<<<<< HEAD\n=======\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n>>>>>>> origin/cc-switch-cli\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn proxy_urls_match(actual: &str, expected: &str) -> bool {\n actual.trim().trim_end_matches('/') == expected.trim().trim_end_matches('/')\n }\n\n fn codex_config_has_base_url_matching(\n config_text: &str,\n predicate: impl Fn(&str) -> bool,\n ) -> bool {\n let Ok(doc) = toml::from_str::<toml::Value>(config_text) else {\n return false;\n };\n\n let active_provider = doc\n .get(\"model_provider\")\n .and_then(|value| value.as_str())\n .map(str::trim)\n .filter(|id| !id.is_empty());\n\n if let Some(provider_id) = active_provider {\n if doc\n .get(\"model_providers\")\n .and_then(|value| value.get(provider_id))\n .and_then(|value| value.get(\"base_url\"))\n .and_then(|value| value.as_str())\n .is_some_and(&predicate)\n {\n return true;\n }\n }\n\n doc.get(\"base_url\")\n .and_then(|value| value.as_str())\n .is_some_and(predicate)\n }\n\n async fn live_takeover_matches_current_proxy(\n &self,\n app_type: &AppType,\n ) -> Result<bool, String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let config = self.read_claude_live()?;\n let base_url_matches = config\n .get(\"env\")\n .and_then(|value| value.get(\"ANTHROPIC_BASE_URL\"))\n .and_then(|value| value.as_str())\n .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url));\n Ok(Self::is_claude_live_taken_over(&config) && base_url_matches)\n }\n AppType::Codex => {\n let config = self.read_codex_live()?;\n let base_url_matches = config\n .get(\"config\")\n .and_then(|value| value.as_str())\n .is_some_and(|config_text| {\n Self::codex_config_has_base_url_matching(config_text, |url| {\n Self::proxy_urls_match(url, &proxy_codex_base_url)\n })\n });\n Ok(Self::codex_live_has_proxy_placeholder(&config) && base_url_matches)\n }\n AppType::Gemini => {\n let config = self.read_gemini_live()?;\n let base_url_matches = config\n .get(\"env\")\n .and_then(|value| value.get(\"GOOGLE_GEMINI_BASE_URL\"))\n .and_then(|value| value.as_str())\n .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url));\n Ok(Self::is_gemini_live_taken_over(&config) && base_url_matches)\n }\n _ => Ok(false),\n }\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n let updated =\n crate::codex_config::remove_codex_experimental_bearer_token_if(&updated, |token| {\n token == PROXY_TOKEN_PLACEHOLDER\n })\n .map_err(|e| format!(\"清理 Codex 接管占位符失败: {e}\"))?;\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn codex_live_has_proxy_placeholder(config: &Value) -> bool {\n if config\n .get(\"auth\")\n .and_then(|v| v.as_object())\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n return true;\n }\n\n config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .and_then(crate::codex_config::extract_codex_experimental_bearer_token)\n .as_deref()\n == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n Self::codex_live_has_proxy_placeholder(config)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n<<<<<<< HEAD\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_from_existing_config(\n &mut effective_settings,\n existing_value,\n )?;\n Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?;\n=======\n let existing_backup = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?;\n\n if let Some(existing_backup) = existing_backup {\n let existing_value: Value = serde_json::from_str(&existing_backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))?;\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n &existing_value,\n )?;\n>>>>>>> origin/cc-switch-cli\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n AppType::OpenCode | AppType::OpenClaw => {\n return Err(format!(\"未知的应用类型: {app_type}\"));\n }\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n<<<<<<< HEAD\n self.hot_switch_provider_inner(app_type, provider_id).await\n }\n\n pub(crate) async fn hot_switch_provider_inner(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n=======\n\n>>>>>>> origin/cc-switch-cli\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n<<<<<<< HEAD\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if has_backup && !live_taken_over && matches!(app_type_enum, AppType::Codex) {\n let effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n &provider,\n )\n .map_err(|e| format!(\"构建 Codex 有效配置失败: {e}\"))?;\n let auth = effective_settings\n .get(\"auth\")\n .ok_or_else(|| \"Codex 供应商缺少 auth 配置\".to_string())?;\n let config_str = effective_settings.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n &effective_settings,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n\n=======\n if let Err(e) = self.cleanup_claude_model_overrides_in_live() {\n log::warn!(\"清理 Claude Live 模型字段失败(不影响热切换结果): {e}\");\n }\n }\n }\n\n>>>>>>> origin/cc-switch-cli\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n<<<<<<< HEAD\n fn preserve_codex_mcp_servers_from_existing_config(\n target_settings: &mut Value,\n existing_config: &Value,\n=======\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n>>>>>>> origin/cc-switch-cli\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n<<<<<<< HEAD\n let existing_config = existing_config\n=======\n let existing_config = existing_backup\n>>>>>>> origin/cc-switch-cli\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n<<<<<<< HEAD\n \"Codex config contains a non-table mcp_servers section; skipping MCP merge\"\n=======\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n>>>>>>> origin/cc-switch-cli\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n<<<<<<< HEAD\n fn preserve_codex_oauth_auth_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n if !crate::settings::preserve_codex_official_auth_on_switch() {\n return Ok(());\n }\n\n let Some(existing_auth) = existing_backup\n .get(\"auth\")\n .filter(|auth| crate::codex_config::codex_auth_has_oauth_login_material(auth))\n .cloned()\n else {\n return Ok(());\n };\n\n let Some(target_obj) = target_settings.as_object_mut() else {\n return Ok(());\n };\n\n let provider_auth = target_obj.get(\"auth\").cloned().unwrap_or_else(|| json!({}));\n if let Some(config_text) = target_obj.get(\"config\").and_then(|value| value.as_str()) {\n let live_config = crate::codex_config::prepare_codex_provider_live_config(\n &provider_auth,\n config_text,\n )\n .map_err(|e| format!(\"更新 Codex 备份配置失败: {e}\"))?;\n target_obj.insert(\"config\".to_string(), json!(live_config));\n }\n target_obj.insert(\"auth\".to_string(), existing_auth);\n\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,并按需刷新代理安全的 Live 显示字段)\n=======\n /// 代理模式下切换供应商(热切换,不写 Live)\n>>>>>>> origin/cc-switch-cli\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n<<<<<<< HEAD\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n=======\n>>>>>>> origin/cc-switch-cli\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n crate::codex_config::read_codex_live_settings()\n .map_err(|e| format!(\"读取 Codex Live 配置失败: {e}\"))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n self.write_codex_live_verbatim(config)\n }\n\n fn write_codex_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n let Some(provider) = provider else {\n if crate::settings::preserve_codex_official_auth_on_switch() {\n if let (Some(auth), Some(config_str)) = (\n config.get(\"auth\"),\n config.get(\"config\").and_then(|v| v.as_str()),\n ) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n let live_config = crate::codex_config::prepare_codex_provider_live_config(\n auth, config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n crate::codex_config::write_codex_live_config_atomic(Some(&live_config))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n return Ok(());\n }\n }\n }\n\n return self.write_codex_live_verbatim(config);\n };\n\n let auth = config\n .get(\"auth\")\n .ok_or_else(|| \"Codex 配置缺少 auth 字段\".to_string())?;\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n config,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))\n }\n\n fn codex_auth_has_proxy_placeholder(auth: &Value) -> bool {\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn write_codex_takeover_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n if crate::settings::preserve_codex_official_auth_on_switch() {\n if let Some(auth) = config\n .get(\"auth\")\n .filter(|auth| Self::codex_auth_has_proxy_placeholder(auth))\n {\n let config_str = config.get(\"config\").and_then(|v| v.as_str()).unwrap_or(\"\");\n let prepared_config =\n crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(\n config, config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n let live_config =\n crate::codex_config::prepare_codex_provider_live_config(auth, &prepared_config)\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n crate::codex_config::write_codex_live_config_atomic(Some(&live_config))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n return Ok(());\n }\n }\n\n self.write_codex_live_for_provider(config, provider)\n }\n\n fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Decide the config.toml text ONCE, before splitting on auth. A stored\n // Codex backup comes in two shapes needing opposite handling:\n // - snapshot backup (`read_codex_live_settings`): no inline `modelCatalog`;\n // the config text already carries the live `model_catalog_json` pointer\n // → keep raw, or projection would strip it.\n // - provider-rebuilt backup (`update_live_backup_from_provider`): inline\n // `modelCatalog` (DB SSOT) with a pointer-less config text → project,\n // or the mapping is lost on restore.\n // The projection decision is orthogonal to auth: a provider-rebuilt backup\n // can pair an inline `modelCatalog` with empty/absent `auth.json` (the key\n // living in the config's `experimental_bearer_token`). Computing it up here\n // keeps every config-writing branch — write-auth, delete-auth, no-auth —\n // consistent instead of letting the empty-auth path skip projection.\n let prepared_cfg = config_str\n .map(|cfg| {\n crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(\n config, cfg,\n )\n })\n .transpose()\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n\n match (auth, prepared_cfg.as_deref()) {\n (Some(auth), Some(cfg)) => {\n let auth_path = get_codex_auth_path();\n if auth.as_object().is_some_and(|obj| obj.is_empty()) {\n let _ = crate::config::delete_file(&auth_path);\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n } else {\n crate::codex_config::write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 fn assert_env_str(env: &Map<String, Value>, key: &str, expected: Option<&str>) {\n assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);\n }\n\n fn seed_codex_model_template() {\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n serde_json::to_string(&serde_json::json!({\n \"models\": [{\n \"slug\": \"gpt-5.5\",\n \"display_name\": \"GPT-5.5\",\n \"model_messages\": { \"instructions_template\": \"t\" },\n \"additional_speed_tiers\": [],\n \"context_window\": 128000\n }]\n }))\n .expect(\"serialize models_cache\"),\n )\n .expect(\"write models_cache.json\");\n }\n\n #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_copilot_models_from_provider() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.5\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-sonnet-4.6\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_API_KEY\": \"stale-key\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"claude-haiku-4.5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_codex_models_from_provider() {\n let mut provider = Provider::with_id(\n \"codex\".to_string(),\n \"Codex\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://chatgpt.com/backend-api/codex\",\n \"ANTHROPIC_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"gpt-5.4-mini\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"gpt-5.4\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"codex_oauth\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale-token\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"gpt-5.4-mini\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_preserves_oauth_auth_json() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_settings = json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"third-party Codex proxy writes must not overwrite ChatGPT OAuth login state\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"experimental_bearer_token\"),\n \"proxy placeholder should move into config.toml instead of auth.json\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"live config should carry the proxy placeholder token\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserves_oauth_auth_json_when_preserve_enabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"cn_official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"Codex takeover should not overwrite ChatGPT OAuth auth when preservation is enabled\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"takeover placeholder should move into config.toml\"\n );\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"Codex takeover detection should recognize config.toml placeholders\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserves_oauth_auth_json_even_when_provider_category_is_official() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"Codex takeover must not rewrite auth.json when preservation is enabled, even if provider category is stale or misclassified\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"takeover placeholder should move into config.toml\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_set_takeover_for_app_preserves_oauth_auth_json_when_preserve_enabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"enable Codex takeover\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"the public takeover command path must not rewrite auth.json when preservation is enabled\"\n );\n\n service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_sync_current_to_live_during_takeover_preserves_oauth_auth_json() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"enable Codex takeover\");\n\n crate::services::provider::ProviderService::sync_current_to_live(&state)\n .expect(\"sync current providers while Codex is taken over\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"post-change provider sync must not rewrite Codex auth.json during takeover\"\n );\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let backup_value: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n assert_eq!(\n backup_value.get(\"auth\"),\n Some(&oauth_auth),\n \"provider-derived takeover backup should preserve official OAuth auth\"\n );\n assert!(\n backup_value\n .get(\"config\")\n .and_then(|value| value.as_str())\n .is_some_and(|config| config.contains(\"deepseek-key\")),\n \"provider token should be carried by config.toml in the restore backup\"\n );\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n let restored_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read restored auth\");\n assert_eq!(\n restored_auth, oauth_auth,\n \"turning takeover off should restore the preserved official OAuth auth\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_sync_current_to_live_during_takeover_activation_keeps_proxy_live_config() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n state\n .proxy_service\n .backup_live_config_strict(&AppType::Codex)\n .await\n .expect(\"backup Codex live config\");\n state\n .proxy_service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n assert!(\n !db.get_proxy_config_for_app(\"codex\")\n .await\n .expect(\"get Codex proxy config\")\n .enabled,\n \"this reproduces the activation window before set_takeover_for_app marks enabled=true\"\n );\n\n crate::services::provider::ProviderService::sync_current_to_live(&state)\n .expect(\"sync current providers during takeover activation\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"activation-time provider sync must not rewrite Codex OAuth auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"activation-time provider sync must keep the proxy bearer placeholder\"\n );\n assert!(\n live_config.contains(\"http://127.0.0.1\"),\n \"activation-time provider sync must keep the local proxy base_url\"\n );\n assert!(\n state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"Codex live config should still be detected as taken over\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_set_takeover_rebuilds_stale_enabled_state_without_overwriting_backup() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let original_deepseek_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n let stale_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"PROXY_MANAGED\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(stale_live_config))\n .expect(\"seed stale Codex live config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": oauth_auth,\n \"config\": original_deepseek_config\n }))\n .expect(\"serialize original backup\"),\n )\n .await\n .expect(\"seed original live backup\");\n let mut proxy_config = db\n .get_proxy_config_for_app(\"codex\")\n .await\n .expect(\"get Codex proxy config\");\n proxy_config.enabled = true;\n db.update_proxy_config_for_app(proxy_config)\n .await\n .expect(\"mark Codex takeover enabled\");\n\n service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"rebuild Codex takeover\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"repairing stale takeover must restore the preserved OAuth auth from backup\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"http://127.0.0.1:15721/v1\"),\n \"stale enabled takeover must be rebuilt to the current proxy base_url\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"rebuilt takeover should keep the proxy bearer placeholder\"\n );\n assert!(\n service\n .live_takeover_matches_current_proxy(&AppType::Codex)\n .await\n .expect(\"detect rebuilt Codex takeover\"),\n \"rebuilt Codex live config should match the active proxy address\"\n );\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get Codex live backup\")\n .expect(\"backup exists\");\n let backup_value: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n assert_eq!(\n backup_value.get(\"auth\"),\n Some(&oauth_auth),\n \"rebuilding stale takeover must not overwrite the original OAuth backup\"\n );\n assert!(\n backup_value\n .get(\"config\")\n .and_then(|value| value.as_str())\n .is_some_and(|config| config.contains(\"deepseek-key\")\n && !config.contains(\"http://127.0.0.1\")),\n \"backup should remain the restorable DeepSeek config, not the proxy config\"\n );\n\n service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserve_disabled_uses_legacy_auth_write_path() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"cn_official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth\n .get(\"OPENAI_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER),\n \"disabled preservation should keep the legacy auth.json takeover placeholder\"\n );\n assert_eq!(\n live_auth\n .get(\"tokens\")\n .and_then(|tokens| tokens.get(\"access_token\"))\n .and_then(|value| value.as_str()),\n Some(\"oauth-access\"),\n \"the new config-only takeover branch must not run when preservation is disabled\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"disabled preservation should not move the takeover placeholder into config.toml\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n #[serial]\n fn codex_takeover_cleanup_removes_config_placeholder_without_touching_oauth_auth() {\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 service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\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 ),\n )\n .expect(\"seed taken-over Codex live config\");\n\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"config.toml placeholder should be detected before cleanup\"\n );\n\n service\n .cleanup_codex_takeover_placeholders_in_live()\n .expect(\"cleanup Codex takeover placeholders\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"cleanup should preserve ChatGPT OAuth auth\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"cleanup should remove config.toml proxy bearer placeholder\"\n );\n assert!(\n !live_config.contains(\"http://127.0.0.1:15721\"),\n \"cleanup should remove local proxy base_url\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_auth = json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n let takeover_settings = json!({\n \"auth\": takeover_auth,\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth,\n json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n }),\n \"disabled preservation should let third-party switches overwrite auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(\"experimental_bearer_token\"),\n \"provider token should stay in auth.json when preservation is disabled\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n<<<<<<< HEAD\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n=======\n \"ANTHROPIC_MODEL\": \"claude-new\"\n>>>>>>> origin/cc-switch-cli\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n<<<<<<< HEAD\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n=======\n \"ANTHROPIC_MODEL\": \"stale-model\"\n>>>>>>> origin/cc-switch-cli\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n<<<<<<< HEAD\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-8[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n=======\n \"Claude model override fields should be removed in takeover mode\"\n>>>>>>> origin/cc-switch-cli\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n<<<<<<< HEAD\n async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider-derived restore backup should preserve the provider's model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"custom\").is_none());\n assert_eq!(\n backup_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 \"provider id should point at the hot-switched provider endpoint\"\n );\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"hot-switched Codex live config should expose the selected provider\"\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .and_then(|v| v.get(\"name\"))\n .and_then(|v| v.as_str()),\n Some(\"AiHubMix\"),\n \"Codex app provider label should follow the selected provider\"\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"http://127.0.0.1:15721/v1\"),\n \"taken-over live config should stay pointed at the local proxy\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"restored Codex live config should preserve the provider's model_provider\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_updates_live_provider_display() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"deepseek\")\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"name\"))\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek\")\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"http://127.0.0.1:15721/v1\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n=======\n>>>>>>> origin/cc-switch-cli\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n<<<<<<< HEAD\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_refreshes_catalog_and_common_config() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n db.set_config_snippet(\n \"codex\",\n Some(\n r#\"[mcp_servers.shared]\ncommand = \"shared-command\"\n\"#\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect(\"provider switch to provider b\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n assert!(\n catalog_path.exists(),\n \"cc-switch-model-catalog.json must be created on provider switch\"\n );\n let catalog_text = std::fs::read_to_string(&catalog_path).expect(\"read catalog json\");\n let catalog: serde_json::Value =\n serde_json::from_str(&catalog_text).expect(\"parse catalog json\");\n let slugs: Vec<&str> = catalog\n .get(\"models\")\n .and_then(|m| m.as_array())\n .map(|arr| {\n arr.iter()\n .filter_map(|e| e.get(\"slug\").and_then(|s| s.as_str()))\n .collect()\n })\n .unwrap_or_default();\n assert!(\n slugs.contains(&\"model-b\"),\n \"catalog must contain provider B's model after switch; got: {slugs:?}\"\n );\n assert!(\n !slugs.contains(&\"model-a\"),\n \"catalog must not contain stale provider A model after switch; got: {slugs:?}\"\n );\n\n let config_path = crate::codex_config::get_codex_config_path();\n let config_text = std::fs::read_to_string(&config_path).expect(\"read config.toml\");\n assert!(\n config_text.contains(\"model_catalog_json\"),\n \"config.toml must reference model_catalog_json after switch\"\n );\n assert!(\n config_text.contains(\"[mcp_servers.shared]\"),\n \"config.toml must keep common config after switch\"\n );\n assert!(\n config_text.contains(r#\"command = \"shared-command\"\"#),\n \"config.toml must include common config content after switch\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_propagates_catalog_write_errors() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n if catalog_path.exists() {\n std::fs::remove_file(&catalog_path).expect(\"remove catalog file\");\n }\n std::fs::create_dir_all(&catalog_path).expect(\"turn catalog path into directory\");\n\n let err = crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect_err(\"provider switch should fail when catalog cannot be written\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let message = err.to_string();\n assert!(\n message.contains(\"写入 Codex 配置失败\") || message.contains(\"原子替换失败\"),\n \"switch should surface catalog write failure, got: {message}\"\n );\n }\n\n /// Regression: turning proxy takeover off restores Live from the backup. The\n /// backup snapshot is `read_codex_live_settings()` output (`{auth, config}`,\n /// never an inline `modelCatalog`). The restore must NOT route the config\n /// through catalog projection, which would see no specs and strip the\n /// `model_catalog_json` pointer — silently dropping the user's Codex model\n /// mapping from Live even though the DB SSOT still holds it.\n #[tokio::test]\n #[serial]\n async fn codex_restore_from_backup_preserves_model_catalog_pointer() {\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 service = ProxyService::new(db.clone());\n\n // Pre-takeover Live state: config.toml points at the cc-switch generated\n // catalog file, and that file exists on disk (takeover never touches it).\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n if let Some(parent) = catalog_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create codex dir\");\n }\n std::fs::write(\n &catalog_path,\n r#\"{\"models\":[{\"slug\":\"deepseek-v4-flash\"}]}\"#,\n )\n .expect(\"seed generated catalog file\");\n\n let pointer = catalog_path.to_string_lossy().to_string();\n let backup_config = format!(\n \"model_provider = \\\"custom\\\"\\n\\\n model = \\\"deepseek-v4-flash\\\"\\n\\\n model_catalog_json = \\\"{pointer}\\\"\\n\\n\\\n [model_providers.custom]\\n\\\n name = \\\"DeepSeek\\\"\\n\\\n base_url = \\\"https://api.deepseek.example/v1\\\"\\n\\\n wire_api = \\\"responses\\\"\\n\"\n );\n let backup_json = serde_json::to_string(&json!({\n \"auth\": { \"OPENAI_API_KEY\": \"deepseek-key\" },\n \"config\": backup_config,\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n // Turning takeover off restores Live from this backup.\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"restore must preserve the model_catalog_json pointer, got:\\n{restored}\"\n );\n assert!(\n restored.contains(pointer.as_str()),\n \"restored pointer must still reference the cc-switch generated catalog file\"\n );\n }\n\n /// Regression: a hot-switch during takeover rebuilds the backup from the DB\n /// provider (`update_live_backup_from_provider`), so the backup carries an\n /// inline `modelCatalog` (DB SSOT) but a `config.toml` text WITHOUT a\n /// `model_catalog_json` pointer. Restoring that backup must project the\n /// inline catalog — (re)generating both the catalog file and the pointer —\n /// or the Codex model mapping vanishes from Live after takeover-off.\n #[tokio::test]\n #[serial]\n async fn codex_restore_from_backup_projects_inline_model_catalog() {\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 service = ProxyService::new(db.clone());\n\n // Catalog projection needs a model template; seed `models_cache.json`\n // with the template slug so we don't depend on the `codex` CLI.\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n r#\"{\"models\":[{\"slug\":\"gpt-5.5\"}]}\"#,\n )\n .expect(\"seed models_cache template\");\n\n // Provider-rebuilt backup shape: inline modelCatalog, pointer-less config.\n let backup_json = serde_json::to_string(&json!({\n \"auth\": { \"OPENAI_API_KEY\": \"deepseek-key\" },\n \"config\": \"model_provider = \\\"custom\\\"\\nmodel = \\\"deepseek-v4-flash\\\"\\n\\n[model_providers.custom]\\nname = \\\"DeepSeek\\\"\\nbase_url = \\\"https://api.deepseek.example/v1\\\"\\nwire_api = \\\"responses\\\"\\n\",\n \"modelCatalog\": {\n \"models\": [\n { \"model\": \"deepseek-v4-flash\", \"displayName\": \"DeepSeek V4 Flash\", \"contextWindow\": 1_000_000 }\n ]\n }\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"restore must (re)generate the model_catalog_json pointer from inline catalog, got:\\n{restored}\"\n );\n assert!(\n catalog_path.exists(),\n \"restore must generate the cc-switch catalog file on disk\"\n );\n let catalog: Value = serde_json::from_str(\n &std::fs::read_to_string(&catalog_path).expect(\"read generated catalog\"),\n )\n .expect(\"parse generated catalog\");\n let slugs: Vec<&str> = catalog\n .get(\"models\")\n .and_then(|m| m.as_array())\n .expect(\"catalog models\")\n .iter()\n .filter_map(|m| m.get(\"slug\").and_then(|s| s.as_str()))\n .collect();\n assert!(\n slugs.contains(&\"deepseek-v4-flash\"),\n \"generated catalog must contain the inline model, got slugs: {slugs:?}\"\n );\n }\n\n /// Regression: a provider-rebuilt backup can pair an inline `modelCatalog`\n /// with EMPTY `auth.json` (`{}`) — the bearer-token / Mobile-compat shape\n /// where the API key lives in the config's `experimental_bearer_token`. The\n /// empty-auth restore branch deletes `auth.json` and writes config raw; it\n /// must still project the inline catalog (decision is orthogonal to auth), or\n /// the model mapping vanishes on takeover-off for this provider shape.\n #[tokio::test]\n #[serial]\n async fn codex_restore_empty_auth_backup_still_projects_inline_catalog() {\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 service = ProxyService::new(db.clone());\n\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n r#\"{\"models\":[{\"slug\":\"gpt-5.5\"}]}\"#,\n )\n .expect(\"seed models_cache template\");\n\n // Empty auth.json + key carried in config.toml's experimental_bearer_token,\n // plus the inline modelCatalog (DB SSOT).\n let backup_json = serde_json::to_string(&json!({\n \"auth\": {},\n \"config\": \"model_provider = \\\"custom\\\"\\nmodel = \\\"deepseek-v4-flash\\\"\\n\\n[model_providers.custom]\\nname = \\\"DeepSeek\\\"\\nbase_url = \\\"https://api.deepseek.example/v1\\\"\\nwire_api = \\\"responses\\\"\\nexperimental_bearer_token = \\\"sk-deepseek\\\"\\n\",\n \"modelCatalog\": {\n \"models\": [ { \"model\": \"deepseek-v4-flash\", \"displayName\": \"DeepSeek V4 Flash\" } ]\n }\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"empty-auth restore must still project the inline catalog pointer, got:\\n{restored}\"\n );\n assert!(\n crate::codex_config::get_codex_model_catalog_path().exists(),\n \"empty-auth restore must generate the cc-switch catalog file\"\n );\n assert!(\n !crate::codex_config::get_codex_auth_path().exists(),\n \"empty-auth restore must delete auth.json rather than write an empty one\"\n );\n }\n=======\n>>>>>>> origin/cc-switch-cli\n}\n"}, "b7499fc871b1348dd905b043eb1bd1332c127bf0": {"t": 1785761118.713976, "n": 198198, "h": "9fb1fe7953851da5", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-8\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。\n let takeover_model_fields = if provider.uses_managed_account_auth() {\n Self::build_claude_takeover_model_fields(&provider.settings_config)\n } else {\n Self::build_claude_takeover_model_fields(config)\n };\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy_and_models(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n takeover_model_fields: Vec<(&'static str, String)>,\n ) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n fn claude_provider_with_effective_settings(\n &self,\n provider: &Provider,\n ) -> Result<Provider, String> {\n let mut effective_provider = provider.clone();\n effective_provider.settings_config = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n Ok(effective_provider)\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let effective_provider = self.claude_provider_with_effective_settings(provider)?;\n let mut effective_settings = effective_provider.settings_config.clone();\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n &effective_provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let existing_live = self.read_codex_live().ok();\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?;\n if let Some(existing_live) = existing_live.as_ref() {\n Self::preserve_codex_mcp_servers_from_existing_config(\n &mut effective_settings,\n existing_live,\n )?;\n }\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_takeover_live_for_provider(&effective_settings, Some(provider))?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n pub(crate) async fn lock_switch_for_app(\n &self,\n app_type: &str,\n ) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n let _guard = self.switch_locks.lock_for_app(app_type_str).await;\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n let mut restore_existing_backup_before_takeover = false;\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_matches_current_proxy =\n match self.live_takeover_matches_current_proxy(&app).await {\n Ok(value) => value,\n Err(e) => {\n log::warn!(\"检测 {app_type_str} 接管配置失败(将继续重建接管): {e}\");\n false\n }\n };\n\n // 必须 backup 存在,且 live 确实指向当前代理地址,才算真接管。\n // 只看占位符会把半接管/旧端口残留误判为可复用,导致开启接管后\n // live 文件仍停留在普通供应商配置。\n if has_backup && live_matches_current_proxy {\n return Ok(());\n }\n restore_existing_backup_before_takeover = has_backup;\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_matches_current_proxy={live_matches_current_proxy},正在重新接管并补齐 Live\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n if restore_existing_backup_before_takeover {\n self.restore_live_config_for_app_inner(&app).await?;\n } else {\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app_inner(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback_inner(&app)\n .await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_takeover_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider =\n self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?;\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(&codex_provider),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n Some(&codex_provider),\n );\n\n self.write_codex_takeover_live_for_provider(&live_config, Some(&codex_provider))?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n let provider = self.claude_provider_with_effective_settings(provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ = self.write_codex_takeover_live_for_provider(\n &live_config,\n codex_provider.as_ref(),\n );\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn proxy_urls_match(actual: &str, expected: &str) -> bool {\n actual.trim().trim_end_matches('/') == expected.trim().trim_end_matches('/')\n }\n\n fn codex_config_has_base_url_matching(\n config_text: &str,\n predicate: impl Fn(&str) -> bool,\n ) -> bool {\n let Ok(doc) = toml::from_str::<toml::Value>(config_text) else {\n return false;\n };\n\n let active_provider = doc\n .get(\"model_provider\")\n .and_then(|value| value.as_str())\n .map(str::trim)\n .filter(|id| !id.is_empty());\n\n if let Some(provider_id) = active_provider {\n if doc\n .get(\"model_providers\")\n .and_then(|value| value.get(provider_id))\n .and_then(|value| value.get(\"base_url\"))\n .and_then(|value| value.as_str())\n .is_some_and(&predicate)\n {\n return true;\n }\n }\n\n doc.get(\"base_url\")\n .and_then(|value| value.as_str())\n .is_some_and(predicate)\n }\n\n async fn live_takeover_matches_current_proxy(\n &self,\n app_type: &AppType,\n ) -> Result<bool, String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let config = self.read_claude_live()?;\n let base_url_matches = config\n .get(\"env\")\n .and_then(|value| value.get(\"ANTHROPIC_BASE_URL\"))\n .and_then(|value| value.as_str())\n .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url));\n Ok(Self::is_claude_live_taken_over(&config) && base_url_matches)\n }\n AppType::Codex => {\n let config = self.read_codex_live()?;\n let base_url_matches = config\n .get(\"config\")\n .and_then(|value| value.as_str())\n .is_some_and(|config_text| {\n Self::codex_config_has_base_url_matching(config_text, |url| {\n Self::proxy_urls_match(url, &proxy_codex_base_url)\n })\n });\n Ok(Self::codex_live_has_proxy_placeholder(&config) && base_url_matches)\n }\n AppType::Gemini => {\n let config = self.read_gemini_live()?;\n let base_url_matches = config\n .get(\"env\")\n .and_then(|value| value.get(\"GOOGLE_GEMINI_BASE_URL\"))\n .and_then(|value| value.as_str())\n .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url));\n Ok(Self::is_gemini_live_taken_over(&config) && base_url_matches)\n }\n _ => Ok(false),\n }\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n let updated =\n crate::codex_config::remove_codex_experimental_bearer_token_if(&updated, |token| {\n token == PROXY_TOKEN_PLACEHOLDER\n })\n .map_err(|e| format!(\"清理 Codex 接管占位符失败: {e}\"))?;\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn codex_live_has_proxy_placeholder(config: &Value) -> bool {\n if config\n .get(\"auth\")\n .and_then(|v| v.as_object())\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n return true;\n }\n\n config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .and_then(crate::codex_config::extract_codex_experimental_bearer_token)\n .as_deref()\n == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n Self::codex_live_has_proxy_placeholder(config)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_from_existing_config(\n &mut effective_settings,\n existing_value,\n )?;\n Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.hot_switch_provider_inner(app_type, provider_id).await\n }\n\n pub(crate) async fn hot_switch_provider_inner(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if has_backup && !live_taken_over && matches!(app_type_enum, AppType::Codex) {\n let effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n &provider,\n )\n .map_err(|e| format!(\"构建 Codex 有效配置失败: {e}\"))?;\n let auth = effective_settings\n .get(\"auth\")\n .ok_or_else(|| \"Codex 供应商缺少 auth 配置\".to_string())?;\n let config_str = effective_settings.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n &effective_settings,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_from_existing_config(\n target_settings: &mut Value,\n existing_config: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n fn preserve_codex_oauth_auth_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n if !crate::settings::preserve_codex_official_auth_on_switch() {\n return Ok(());\n }\n\n let Some(existing_auth) = existing_backup\n .get(\"auth\")\n .filter(|auth| crate::codex_config::codex_auth_has_oauth_login_material(auth))\n .cloned()\n else {\n return Ok(());\n };\n\n let Some(target_obj) = target_settings.as_object_mut() else {\n return Ok(());\n };\n\n let provider_auth = target_obj.get(\"auth\").cloned().unwrap_or_else(|| json!({}));\n if let Some(config_text) = target_obj.get(\"config\").and_then(|value| value.as_str()) {\n let live_config = crate::codex_config::prepare_codex_provider_live_config(\n &provider_auth,\n config_text,\n )\n .map_err(|e| format!(\"更新 Codex 备份配置失败: {e}\"))?;\n target_obj.insert(\"config\".to_string(), json!(live_config));\n }\n target_obj.insert(\"auth\".to_string(), existing_auth);\n\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,并按需刷新代理安全的 Live 显示字段)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n crate::codex_config::read_codex_live_settings()\n .map_err(|e| format!(\"读取 Codex Live 配置失败: {e}\"))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n self.write_codex_live_verbatim(config)\n }\n\n fn write_codex_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n let Some(provider) = provider else {\n if crate::settings::preserve_codex_official_auth_on_switch() {\n if let (Some(auth), Some(config_str)) = (\n config.get(\"auth\"),\n config.get(\"config\").and_then(|v| v.as_str()),\n ) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n let live_config = crate::codex_config::prepare_codex_provider_live_config(\n auth, config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n crate::codex_config::write_codex_live_config_atomic(Some(&live_config))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n return Ok(());\n }\n }\n }\n\n return self.write_codex_live_verbatim(config);\n };\n\n let auth = config\n .get(\"auth\")\n .ok_or_else(|| \"Codex 配置缺少 auth 字段\".to_string())?;\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n config,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))\n }\n\n fn codex_auth_has_proxy_placeholder(auth: &Value) -> bool {\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn write_codex_takeover_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n if crate::settings::preserve_codex_official_auth_on_switch() {\n if let Some(auth) = config\n .get(\"auth\")\n .filter(|auth| Self::codex_auth_has_proxy_placeholder(auth))\n {\n let config_str = config.get(\"config\").and_then(|v| v.as_str()).unwrap_or(\"\");\n let prepared_config =\n crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(\n config, config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n let live_config =\n crate::codex_config::prepare_codex_provider_live_config(auth, &prepared_config)\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n crate::codex_config::write_codex_live_config_atomic(Some(&live_config))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n return Ok(());\n }\n }\n\n self.write_codex_live_for_provider(config, provider)\n }\n\n fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Decide the config.toml text ONCE, before splitting on auth. A stored\n // Codex backup comes in two shapes needing opposite handling:\n // - snapshot backup (`read_codex_live_settings`): no inline `modelCatalog`;\n // the config text already carries the live `model_catalog_json` pointer\n // → keep raw, or projection would strip it.\n // - provider-rebuilt backup (`update_live_backup_from_provider`): inline\n // `modelCatalog` (DB SSOT) with a pointer-less config text → project,\n // or the mapping is lost on restore.\n // The projection decision is orthogonal to auth: a provider-rebuilt backup\n // can pair an inline `modelCatalog` with empty/absent `auth.json` (the key\n // living in the config's `experimental_bearer_token`). Computing it up here\n // keeps every config-writing branch — write-auth, delete-auth, no-auth —\n // consistent instead of letting the empty-auth path skip projection.\n let prepared_cfg = config_str\n .map(|cfg| {\n crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(\n config, cfg,\n )\n })\n .transpose()\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n\n match (auth, prepared_cfg.as_deref()) {\n (Some(auth), Some(cfg)) => {\n let auth_path = get_codex_auth_path();\n if auth.as_object().is_some_and(|obj| obj.is_empty()) {\n let _ = crate::config::delete_file(&auth_path);\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n } else {\n crate::codex_config::write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 fn assert_env_str(env: &Map<String, Value>, key: &str, expected: Option<&str>) {\n assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);\n }\n\n fn seed_codex_model_template() {\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n serde_json::to_string(&serde_json::json!({\n \"models\": [{\n \"slug\": \"gpt-5.5\",\n \"display_name\": \"GPT-5.5\",\n \"model_messages\": { \"instructions_template\": \"t\" },\n \"additional_speed_tiers\": [],\n \"context_window\": 128000\n }]\n }))\n .expect(\"serialize models_cache\"),\n )\n .expect(\"write models_cache.json\");\n }\n\n #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_copilot_models_from_provider() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.5\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-sonnet-4.6\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_API_KEY\": \"stale-key\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"claude-haiku-4.5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_codex_models_from_provider() {\n let mut provider = Provider::with_id(\n \"codex\".to_string(),\n \"Codex\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://chatgpt.com/backend-api/codex\",\n \"ANTHROPIC_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"gpt-5.4-mini\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"gpt-5.4\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"codex_oauth\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale-token\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"gpt-5.4-mini\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_preserves_oauth_auth_json() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_settings = json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"third-party Codex proxy writes must not overwrite ChatGPT OAuth login state\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"experimental_bearer_token\"),\n \"proxy placeholder should move into config.toml instead of auth.json\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"live config should carry the proxy placeholder token\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserves_oauth_auth_json_when_preserve_enabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"cn_official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"Codex takeover should not overwrite ChatGPT OAuth auth when preservation is enabled\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"takeover placeholder should move into config.toml\"\n );\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"Codex takeover detection should recognize config.toml placeholders\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserves_oauth_auth_json_even_when_provider_category_is_official() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"Codex takeover must not rewrite auth.json when preservation is enabled, even if provider category is stale or misclassified\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"takeover placeholder should move into config.toml\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_set_takeover_for_app_preserves_oauth_auth_json_when_preserve_enabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"enable Codex takeover\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"the public takeover command path must not rewrite auth.json when preservation is enabled\"\n );\n\n service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_sync_current_to_live_during_takeover_preserves_oauth_auth_json() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"enable Codex takeover\");\n\n crate::services::provider::ProviderService::sync_current_to_live(&state)\n .expect(\"sync current providers while Codex is taken over\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"post-change provider sync must not rewrite Codex auth.json during takeover\"\n );\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let backup_value: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n assert_eq!(\n backup_value.get(\"auth\"),\n Some(&oauth_auth),\n \"provider-derived takeover backup should preserve official OAuth auth\"\n );\n assert!(\n backup_value\n .get(\"config\")\n .and_then(|value| value.as_str())\n .is_some_and(|config| config.contains(\"deepseek-key\")),\n \"provider token should be carried by config.toml in the restore backup\"\n );\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n let restored_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read restored auth\");\n assert_eq!(\n restored_auth, oauth_auth,\n \"turning takeover off should restore the preserved official OAuth auth\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_sync_current_to_live_during_takeover_activation_keeps_proxy_live_config() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n state\n .proxy_service\n .backup_live_config_strict(&AppType::Codex)\n .await\n .expect(\"backup Codex live config\");\n state\n .proxy_service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n assert!(\n !db.get_proxy_config_for_app(\"codex\")\n .await\n .expect(\"get Codex proxy config\")\n .enabled,\n \"this reproduces the activation window before set_takeover_for_app marks enabled=true\"\n );\n\n crate::services::provider::ProviderService::sync_current_to_live(&state)\n .expect(\"sync current providers during takeover activation\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"activation-time provider sync must not rewrite Codex OAuth auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"activation-time provider sync must keep the proxy bearer placeholder\"\n );\n assert!(\n live_config.contains(\"http://127.0.0.1\"),\n \"activation-time provider sync must keep the local proxy base_url\"\n );\n assert!(\n state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"Codex live config should still be detected as taken over\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_set_takeover_rebuilds_stale_enabled_state_without_overwriting_backup() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let original_deepseek_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n let stale_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"PROXY_MANAGED\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(stale_live_config))\n .expect(\"seed stale Codex live config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": oauth_auth,\n \"config\": original_deepseek_config\n }))\n .expect(\"serialize original backup\"),\n )\n .await\n .expect(\"seed original live backup\");\n let mut proxy_config = db\n .get_proxy_config_for_app(\"codex\")\n .await\n .expect(\"get Codex proxy config\");\n proxy_config.enabled = true;\n db.update_proxy_config_for_app(proxy_config)\n .await\n .expect(\"mark Codex takeover enabled\");\n\n service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"rebuild Codex takeover\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"repairing stale takeover must restore the preserved OAuth auth from backup\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"http://127.0.0.1:15721/v1\"),\n \"stale enabled takeover must be rebuilt to the current proxy base_url\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"rebuilt takeover should keep the proxy bearer placeholder\"\n );\n assert!(\n service\n .live_takeover_matches_current_proxy(&AppType::Codex)\n .await\n .expect(\"detect rebuilt Codex takeover\"),\n \"rebuilt Codex live config should match the active proxy address\"\n );\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get Codex live backup\")\n .expect(\"backup exists\");\n let backup_value: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n assert_eq!(\n backup_value.get(\"auth\"),\n Some(&oauth_auth),\n \"rebuilding stale takeover must not overwrite the original OAuth backup\"\n );\n assert!(\n backup_value\n .get(\"config\")\n .and_then(|value| value.as_str())\n .is_some_and(|config| config.contains(\"deepseek-key\")\n && !config.contains(\"http://127.0.0.1\")),\n \"backup should remain the restorable DeepSeek config, not the proxy config\"\n );\n\n service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserve_disabled_uses_legacy_auth_write_path() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"cn_official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth\n .get(\"OPENAI_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER),\n \"disabled preservation should keep the legacy auth.json takeover placeholder\"\n );\n assert_eq!(\n live_auth\n .get(\"tokens\")\n .and_then(|tokens| tokens.get(\"access_token\"))\n .and_then(|value| value.as_str()),\n Some(\"oauth-access\"),\n \"the new config-only takeover branch must not run when preservation is disabled\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"disabled preservation should not move the takeover placeholder into config.toml\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n #[serial]\n fn codex_takeover_cleanup_removes_config_placeholder_without_touching_oauth_auth() {\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 service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\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 ),\n )\n .expect(\"seed taken-over Codex live config\");\n\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"config.toml placeholder should be detected before cleanup\"\n );\n\n service\n .cleanup_codex_takeover_placeholders_in_live()\n .expect(\"cleanup Codex takeover placeholders\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"cleanup should preserve ChatGPT OAuth auth\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"cleanup should remove config.toml proxy bearer placeholder\"\n );\n assert!(\n !live_config.contains(\"http://127.0.0.1:15721\"),\n \"cleanup should remove local proxy base_url\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_auth = json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n let takeover_settings = json!({\n \"auth\": takeover_auth,\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth,\n json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n }),\n \"disabled preservation should let third-party switches overwrite auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(\"experimental_bearer_token\"),\n \"provider token should stay in auth.json when preservation is disabled\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-8[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider-derived restore backup should preserve the provider's model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"custom\").is_none());\n assert_eq!(\n backup_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 \"provider id should point at the hot-switched provider endpoint\"\n );\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"hot-switched Codex live config should expose the selected provider\"\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .and_then(|v| v.get(\"name\"))\n .and_then(|v| v.as_str()),\n Some(\"AiHubMix\"),\n \"Codex app provider label should follow the selected provider\"\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"aihubmix\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"http://127.0.0.1:15721/v1\"),\n \"taken-over live config should stay pointed at the local proxy\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"restored Codex live config should preserve the provider's model_provider\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_updates_live_provider_display() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"deepseek\")\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"name\"))\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek\")\n );\n assert_eq!(\n parsed_live\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"http://127.0.0.1:15721/v1\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_refreshes_catalog_and_common_config() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n db.set_config_snippet(\n \"codex\",\n Some(\n r#\"[mcp_servers.shared]\ncommand = \"shared-command\"\n\"#\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect(\"provider switch to provider b\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n assert!(\n catalog_path.exists(),\n \"cc-switch-model-catalog.json must be created on provider switch\"\n );\n let catalog_text = std::fs::read_to_string(&catalog_path).expect(\"read catalog json\");\n let catalog: serde_json::Value =\n serde_json::from_str(&catalog_text).expect(\"parse catalog json\");\n let slugs: Vec<&str> = catalog\n .get(\"models\")\n .and_then(|m| m.as_array())\n .map(|arr| {\n arr.iter()\n .filter_map(|e| e.get(\"slug\").and_then(|s| s.as_str()))\n .collect()\n })\n .unwrap_or_default();\n assert!(\n slugs.contains(&\"model-b\"),\n \"catalog must contain provider B's model after switch; got: {slugs:?}\"\n );\n assert!(\n !slugs.contains(&\"model-a\"),\n \"catalog must not contain stale provider A model after switch; got: {slugs:?}\"\n );\n\n let config_path = crate::codex_config::get_codex_config_path();\n let config_text = std::fs::read_to_string(&config_path).expect(\"read config.toml\");\n assert!(\n config_text.contains(\"model_catalog_json\"),\n \"config.toml must reference model_catalog_json after switch\"\n );\n assert!(\n config_text.contains(\"[mcp_servers.shared]\"),\n \"config.toml must keep common config after switch\"\n );\n assert!(\n config_text.contains(r#\"command = \"shared-command\"\"#),\n \"config.toml must include common config content after switch\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_propagates_catalog_write_errors() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n if catalog_path.exists() {\n std::fs::remove_file(&catalog_path).expect(\"remove catalog file\");\n }\n std::fs::create_dir_all(&catalog_path).expect(\"turn catalog path into directory\");\n\n let err = crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect_err(\"provider switch should fail when catalog cannot be written\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let message = err.to_string();\n assert!(\n message.contains(\"写入 Codex 配置失败\") || message.contains(\"原子替换失败\"),\n \"switch should surface catalog write failure, got: {message}\"\n );\n }\n\n /// Regression: turning proxy takeover off restores Live from the backup. The\n /// backup snapshot is `read_codex_live_settings()` output (`{auth, config}`,\n /// never an inline `modelCatalog`). The restore must NOT route the config\n /// through catalog projection, which would see no specs and strip the\n /// `model_catalog_json` pointer — silently dropping the user's Codex model\n /// mapping from Live even though the DB SSOT still holds it.\n #[tokio::test]\n #[serial]\n async fn codex_restore_from_backup_preserves_model_catalog_pointer() {\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 service = ProxyService::new(db.clone());\n\n // Pre-takeover Live state: config.toml points at the cc-switch generated\n // catalog file, and that file exists on disk (takeover never touches it).\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n if let Some(parent) = catalog_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create codex dir\");\n }\n std::fs::write(\n &catalog_path,\n r#\"{\"models\":[{\"slug\":\"deepseek-v4-flash\"}]}\"#,\n )\n .expect(\"seed generated catalog file\");\n\n let pointer = catalog_path.to_string_lossy().to_string();\n let backup_config = format!(\n \"model_provider = \\\"custom\\\"\\n\\\n model = \\\"deepseek-v4-flash\\\"\\n\\\n model_catalog_json = \\\"{pointer}\\\"\\n\\n\\\n [model_providers.custom]\\n\\\n name = \\\"DeepSeek\\\"\\n\\\n base_url = \\\"https://api.deepseek.example/v1\\\"\\n\\\n wire_api = \\\"responses\\\"\\n\"\n );\n let backup_json = serde_json::to_string(&json!({\n \"auth\": { \"OPENAI_API_KEY\": \"deepseek-key\" },\n \"config\": backup_config,\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n // Turning takeover off restores Live from this backup.\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"restore must preserve the model_catalog_json pointer, got:\\n{restored}\"\n );\n assert!(\n restored.contains(pointer.as_str()),\n \"restored pointer must still reference the cc-switch generated catalog file\"\n );\n }\n\n /// Regression: a hot-switch during takeover rebuilds the backup from the DB\n /// provider (`update_live_backup_from_provider`), so the backup carries an\n /// inline `modelCatalog` (DB SSOT) but a `config.toml` text WITHOUT a\n /// `model_catalog_json` pointer. Restoring that backup must project the\n /// inline catalog — (re)generating both the catalog file and the pointer —\n /// or the Codex model mapping vanishes from Live after takeover-off.\n #[tokio::test]\n #[serial]\n async fn codex_restore_from_backup_projects_inline_model_catalog() {\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 service = ProxyService::new(db.clone());\n\n // Catalog projection needs a model template; seed `models_cache.json`\n // with the template slug so we don't depend on the `codex` CLI.\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n r#\"{\"models\":[{\"slug\":\"gpt-5.5\"}]}\"#,\n )\n .expect(\"seed models_cache template\");\n\n // Provider-rebuilt backup shape: inline modelCatalog, pointer-less config.\n let backup_json = serde_json::to_string(&json!({\n \"auth\": { \"OPENAI_API_KEY\": \"deepseek-key\" },\n \"config\": \"model_provider = \\\"custom\\\"\\nmodel = \\\"deepseek-v4-flash\\\"\\n\\n[model_providers.custom]\\nname = \\\"DeepSeek\\\"\\nbase_url = \\\"https://api.deepseek.example/v1\\\"\\nwire_api = \\\"responses\\\"\\n\",\n \"modelCatalog\": {\n \"models\": [\n { \"model\": \"deepseek-v4-flash\", \"displayName\": \"DeepSeek V4 Flash\", \"contextWindow\": 1_000_000 }\n ]\n }\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"restore must (re)generate the model_catalog_json pointer from inline catalog, got:\\n{restored}\"\n );\n assert!(\n catalog_path.exists(),\n \"restore must generate the cc-switch catalog file on disk\"\n );\n let catalog: Value = serde_json::from_str(\n &std::fs::read_to_string(&catalog_path).expect(\"read generated catalog\"),\n )\n .expect(\"parse generated catalog\");\n let slugs: Vec<&str> = catalog\n .get(\"models\")\n .and_then(|m| m.as_array())\n .expect(\"catalog models\")\n .iter()\n .filter_map(|m| m.get(\"slug\").and_then(|s| s.as_str()))\n .collect();\n assert!(\n slugs.contains(&\"deepseek-v4-flash\"),\n \"generated catalog must contain the inline model, got slugs: {slugs:?}\"\n );\n }\n\n /// Regression: a provider-rebuilt backup can pair an inline `modelCatalog`\n /// with EMPTY `auth.json` (`{}`) — the bearer-token / Mobile-compat shape\n /// where the API key lives in the config's `experimental_bearer_token`. The\n /// empty-auth restore branch deletes `auth.json` and writes config raw; it\n /// must still project the inline catalog (decision is orthogonal to auth), or\n /// the model mapping vanishes on takeover-off for this provider shape.\n #[tokio::test]\n #[serial]\n async fn codex_restore_empty_auth_backup_still_projects_inline_catalog() {\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 service = ProxyService::new(db.clone());\n\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n r#\"{\"models\":[{\"slug\":\"gpt-5.5\"}]}\"#,\n )\n .expect(\"seed models_cache template\");\n\n // Empty auth.json + key carried in config.toml's experimental_bearer_token,\n // plus the inline modelCatalog (DB SSOT).\n let backup_json = serde_json::to_string(&json!({\n \"auth\": {},\n \"config\": \"model_provider = \\\"custom\\\"\\nmodel = \\\"deepseek-v4-flash\\\"\\n\\n[model_providers.custom]\\nname = \\\"DeepSeek\\\"\\nbase_url = \\\"https://api.deepseek.example/v1\\\"\\nwire_api = \\\"responses\\\"\\nexperimental_bearer_token = \\\"sk-deepseek\\\"\\n\",\n \"modelCatalog\": {\n \"models\": [ { \"model\": \"deepseek-v4-flash\", \"displayName\": \"DeepSeek V4 Flash\" } ]\n }\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"empty-auth restore must still project the inline catalog pointer, got:\\n{restored}\"\n );\n assert!(\n crate::codex_config::get_codex_model_catalog_path().exists(),\n \"empty-auth restore must generate the cc-switch catalog file\"\n );\n assert!(\n !crate::codex_config::get_codex_auth_path().exists(),\n \"empty-auth restore must delete auth.json rather than write an empty one\"\n );\n }\n}\n"}, "2a131a5572073e4ba194149d200706ebf7ab6ccb": {"t": 1785761120.7412958, "n": 196255, "h": "f4e8a9df878060eb", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-8\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。\n let takeover_model_fields = if provider.uses_managed_account_auth() {\n Self::build_claude_takeover_model_fields(&provider.settings_config)\n } else {\n Self::build_claude_takeover_model_fields(config)\n };\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy_and_models(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n takeover_model_fields: Vec<(&'static str, String)>,\n ) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n fn claude_provider_with_effective_settings(\n &self,\n provider: &Provider,\n ) -> Result<Provider, String> {\n let mut effective_provider = provider.clone();\n effective_provider.settings_config = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n Ok(effective_provider)\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let effective_provider = self.claude_provider_with_effective_settings(provider)?;\n let mut effective_settings = effective_provider.settings_config.clone();\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n &effective_provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = match self.read_codex_live() {\n Ok(config) => config,\n Err(_) => build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?,\n };\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_takeover_live_for_provider(&effective_settings, Some(provider))?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n pub(crate) async fn lock_switch_for_app(\n &self,\n app_type: &str,\n ) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n let _guard = self.switch_locks.lock_for_app(app_type_str).await;\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n let mut restore_existing_backup_before_takeover = false;\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_matches_current_proxy =\n match self.live_takeover_matches_current_proxy(&app).await {\n Ok(value) => value,\n Err(e) => {\n log::warn!(\"检测 {app_type_str} 接管配置失败(将继续重建接管): {e}\");\n false\n }\n };\n\n // 必须 backup 存在,且 live 确实指向当前代理地址,才算真接管。\n // 只看占位符会把半接管/旧端口残留误判为可复用,导致开启接管后\n // live 文件仍停留在普通供应商配置。\n if has_backup && live_matches_current_proxy {\n return Ok(());\n }\n restore_existing_backup_before_takeover = has_backup;\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_matches_current_proxy={live_matches_current_proxy},正在重新接管并补齐 Live\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n if restore_existing_backup_before_takeover {\n self.restore_live_config_for_app_inner(&app).await?;\n } else {\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app_inner(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback_inner(&app)\n .await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_takeover_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider =\n self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?;\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(&codex_provider),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n Some(&codex_provider),\n );\n\n self.write_codex_takeover_live_for_provider(&live_config, Some(&codex_provider))?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n let provider = self.claude_provider_with_effective_settings(provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ = self.write_codex_takeover_live_for_provider(\n &live_config,\n codex_provider.as_ref(),\n );\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn proxy_urls_match(actual: &str, expected: &str) -> bool {\n actual.trim().trim_end_matches('/') == expected.trim().trim_end_matches('/')\n }\n\n fn codex_config_has_base_url_matching(\n config_text: &str,\n predicate: impl Fn(&str) -> bool,\n ) -> bool {\n let Ok(doc) = toml::from_str::<toml::Value>(config_text) else {\n return false;\n };\n\n let active_provider = doc\n .get(\"model_provider\")\n .and_then(|value| value.as_str())\n .map(str::trim)\n .filter(|id| !id.is_empty());\n\n if let Some(provider_id) = active_provider {\n if doc\n .get(\"model_providers\")\n .and_then(|value| value.get(provider_id))\n .and_then(|value| value.get(\"base_url\"))\n .and_then(|value| value.as_str())\n .is_some_and(&predicate)\n {\n return true;\n }\n }\n\n doc.get(\"base_url\")\n .and_then(|value| value.as_str())\n .is_some_and(predicate)\n }\n\n async fn live_takeover_matches_current_proxy(\n &self,\n app_type: &AppType,\n ) -> Result<bool, String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let config = self.read_claude_live()?;\n let base_url_matches = config\n .get(\"env\")\n .and_then(|value| value.get(\"ANTHROPIC_BASE_URL\"))\n .and_then(|value| value.as_str())\n .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url));\n Ok(Self::is_claude_live_taken_over(&config) && base_url_matches)\n }\n AppType::Codex => {\n let config = self.read_codex_live()?;\n let base_url_matches = config\n .get(\"config\")\n .and_then(|value| value.as_str())\n .is_some_and(|config_text| {\n Self::codex_config_has_base_url_matching(config_text, |url| {\n Self::proxy_urls_match(url, &proxy_codex_base_url)\n })\n });\n Ok(Self::codex_live_has_proxy_placeholder(&config) && base_url_matches)\n }\n AppType::Gemini => {\n let config = self.read_gemini_live()?;\n let base_url_matches = config\n .get(\"env\")\n .and_then(|value| value.get(\"GOOGLE_GEMINI_BASE_URL\"))\n .and_then(|value| value.as_str())\n .is_some_and(|url| Self::proxy_urls_match(url, &proxy_url));\n Ok(Self::is_gemini_live_taken_over(&config) && base_url_matches)\n }\n _ => Ok(false),\n }\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n let updated =\n crate::codex_config::remove_codex_experimental_bearer_token_if(&updated, |token| {\n token == PROXY_TOKEN_PLACEHOLDER\n })\n .map_err(|e| format!(\"清理 Codex 接管占位符失败: {e}\"))?;\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn codex_live_has_proxy_placeholder(config: &Value) -> bool {\n if config\n .get(\"auth\")\n .and_then(|v| v.as_object())\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n return true;\n }\n\n config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .and_then(crate::codex_config::extract_codex_experimental_bearer_token)\n .as_deref()\n == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n Self::codex_live_has_proxy_placeholder(config)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n Self::preserve_codex_oauth_auth_in_backup(&mut effective_settings, existing_value)?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.hot_switch_provider_inner(app_type, provider_id).await\n }\n\n pub(crate) async fn hot_switch_provider_inner(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if has_backup && !live_taken_over && matches!(app_type_enum, AppType::Codex) {\n let effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n &provider,\n )\n .map_err(|e| format!(\"构建 Codex 有效配置失败: {e}\"))?;\n let auth = effective_settings\n .get(\"auth\")\n .ok_or_else(|| \"Codex 供应商缺少 auth 配置\".to_string())?;\n let config_str = effective_settings.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n &effective_settings,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n fn preserve_codex_oauth_auth_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n if !crate::settings::preserve_codex_official_auth_on_switch() {\n return Ok(());\n }\n\n let Some(existing_auth) = existing_backup\n .get(\"auth\")\n .filter(|auth| crate::codex_config::codex_auth_has_oauth_login_material(auth))\n .cloned()\n else {\n return Ok(());\n };\n\n let Some(target_obj) = target_settings.as_object_mut() else {\n return Ok(());\n };\n\n let provider_auth = target_obj.get(\"auth\").cloned().unwrap_or_else(|| json!({}));\n if let Some(config_text) = target_obj.get(\"config\").and_then(|value| value.as_str()) {\n let live_config = crate::codex_config::prepare_codex_provider_live_config(\n &provider_auth,\n config_text,\n )\n .map_err(|e| format!(\"更新 Codex 备份配置失败: {e}\"))?;\n target_obj.insert(\"config\".to_string(), json!(live_config));\n }\n target_obj.insert(\"auth\".to_string(), existing_auth);\n\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n crate::codex_config::read_codex_live_settings()\n .map_err(|e| format!(\"读取 Codex Live 配置失败: {e}\"))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n self.write_codex_live_verbatim(config)\n }\n\n fn write_codex_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n let Some(provider) = provider else {\n if crate::settings::preserve_codex_official_auth_on_switch() {\n if let (Some(auth), Some(config_str)) = (\n config.get(\"auth\"),\n config.get(\"config\").and_then(|v| v.as_str()),\n ) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n let live_config = crate::codex_config::prepare_codex_provider_live_config(\n auth, config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n crate::codex_config::write_codex_live_config_atomic(Some(&live_config))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n return Ok(());\n }\n }\n }\n\n return self.write_codex_live_verbatim(config);\n };\n\n let auth = config\n .get(\"auth\")\n .ok_or_else(|| \"Codex 配置缺少 auth 字段\".to_string())?;\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n config,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))\n }\n\n fn codex_auth_has_proxy_placeholder(auth: &Value) -> bool {\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn write_codex_takeover_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n if crate::settings::preserve_codex_official_auth_on_switch() {\n if let Some(auth) = config\n .get(\"auth\")\n .filter(|auth| Self::codex_auth_has_proxy_placeholder(auth))\n {\n let config_str = config.get(\"config\").and_then(|v| v.as_str()).unwrap_or(\"\");\n let prepared_config =\n crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(\n config, config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n let live_config =\n crate::codex_config::prepare_codex_provider_live_config(auth, &prepared_config)\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n crate::codex_config::write_codex_live_config_atomic(Some(&live_config))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n return Ok(());\n }\n }\n\n self.write_codex_live_for_provider(config, provider)\n }\n\n fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Decide the config.toml text ONCE, before splitting on auth. A stored\n // Codex backup comes in two shapes needing opposite handling:\n // - snapshot backup (`read_codex_live_settings`): no inline `modelCatalog`;\n // the config text already carries the live `model_catalog_json` pointer\n // → keep raw, or projection would strip it.\n // - provider-rebuilt backup (`update_live_backup_from_provider`): inline\n // `modelCatalog` (DB SSOT) with a pointer-less config text → project,\n // or the mapping is lost on restore.\n // The projection decision is orthogonal to auth: a provider-rebuilt backup\n // can pair an inline `modelCatalog` with empty/absent `auth.json` (the key\n // living in the config's `experimental_bearer_token`). Computing it up here\n // keeps every config-writing branch — write-auth, delete-auth, no-auth —\n // consistent instead of letting the empty-auth path skip projection.\n let prepared_cfg = config_str\n .map(|cfg| {\n crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(\n config, cfg,\n )\n })\n .transpose()\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n\n match (auth, prepared_cfg.as_deref()) {\n (Some(auth), Some(cfg)) => {\n let auth_path = get_codex_auth_path();\n if auth.as_object().is_some_and(|obj| obj.is_empty()) {\n let _ = crate::config::delete_file(&auth_path);\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n } else {\n crate::codex_config::write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 fn assert_env_str(env: &Map<String, Value>, key: &str, expected: Option<&str>) {\n assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);\n }\n\n fn seed_codex_model_template() {\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n serde_json::to_string(&serde_json::json!({\n \"models\": [{\n \"slug\": \"gpt-5.5\",\n \"display_name\": \"GPT-5.5\",\n \"model_messages\": { \"instructions_template\": \"t\" },\n \"additional_speed_tiers\": [],\n \"context_window\": 128000\n }]\n }))\n .expect(\"serialize models_cache\"),\n )\n .expect(\"write models_cache.json\");\n }\n\n #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_copilot_models_from_provider() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.5\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-sonnet-4.6\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_API_KEY\": \"stale-key\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"claude-haiku-4.5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_codex_models_from_provider() {\n let mut provider = Provider::with_id(\n \"codex\".to_string(),\n \"Codex\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://chatgpt.com/backend-api/codex\",\n \"ANTHROPIC_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"gpt-5.4-mini\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"gpt-5.4\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"codex_oauth\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale-token\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"gpt-5.4-mini\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_preserves_oauth_auth_json() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_settings = json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"third-party Codex proxy writes must not overwrite ChatGPT OAuth login state\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"experimental_bearer_token\"),\n \"proxy placeholder should move into config.toml instead of auth.json\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"live config should carry the proxy placeholder token\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserves_oauth_auth_json_when_preserve_enabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"cn_official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"Codex takeover should not overwrite ChatGPT OAuth auth when preservation is enabled\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"takeover placeholder should move into config.toml\"\n );\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"Codex takeover detection should recognize config.toml placeholders\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserves_oauth_auth_json_even_when_provider_category_is_official() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"Codex takeover must not rewrite auth.json when preservation is enabled, even if provider category is stale or misclassified\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"takeover placeholder should move into config.toml\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_set_takeover_for_app_preserves_oauth_auth_json_when_preserve_enabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"enable Codex takeover\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"the public takeover command path must not rewrite auth.json when preservation is enabled\"\n );\n\n service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_sync_current_to_live_during_takeover_preserves_oauth_auth_json() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"enable Codex takeover\");\n\n crate::services::provider::ProviderService::sync_current_to_live(&state)\n .expect(\"sync current providers while Codex is taken over\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"post-change provider sync must not rewrite Codex auth.json during takeover\"\n );\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let backup_value: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n assert_eq!(\n backup_value.get(\"auth\"),\n Some(&oauth_auth),\n \"provider-derived takeover backup should preserve official OAuth auth\"\n );\n assert!(\n backup_value\n .get(\"config\")\n .and_then(|value| value.as_str())\n .is_some_and(|config| config.contains(\"deepseek-key\")),\n \"provider token should be carried by config.toml in the restore backup\"\n );\n\n state\n .proxy_service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n let restored_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read restored auth\");\n assert_eq!(\n restored_auth, oauth_auth,\n \"turning takeover off should restore the preserved official OAuth auth\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_sync_current_to_live_during_takeover_activation_keeps_proxy_live_config() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n state\n .proxy_service\n .backup_live_config_strict(&AppType::Codex)\n .await\n .expect(\"backup Codex live config\");\n state\n .proxy_service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n assert!(\n !db.get_proxy_config_for_app(\"codex\")\n .await\n .expect(\"get Codex proxy config\")\n .enabled,\n \"this reproduces the activation window before set_takeover_for_app marks enabled=true\"\n );\n\n crate::services::provider::ProviderService::sync_current_to_live(&state)\n .expect(\"sync current providers during takeover activation\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"activation-time provider sync must not rewrite Codex OAuth auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"activation-time provider sync must keep the proxy bearer placeholder\"\n );\n assert!(\n live_config.contains(\"http://127.0.0.1\"),\n \"activation-time provider sync must keep the local proxy base_url\"\n );\n assert!(\n state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"Codex live config should still be detected as taken over\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_set_takeover_rebuilds_stale_enabled_state_without_overwriting_backup() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let original_deepseek_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n let stale_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"PROXY_MANAGED\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(stale_live_config))\n .expect(\"seed stale Codex live config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": oauth_auth,\n \"config\": original_deepseek_config\n }))\n .expect(\"serialize original backup\"),\n )\n .await\n .expect(\"seed original live backup\");\n let mut proxy_config = db\n .get_proxy_config_for_app(\"codex\")\n .await\n .expect(\"get Codex proxy config\");\n proxy_config.enabled = true;\n db.update_proxy_config_for_app(proxy_config)\n .await\n .expect(\"mark Codex takeover enabled\");\n\n service\n .set_takeover_for_app(\"codex\", true)\n .await\n .expect(\"rebuild Codex takeover\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"repairing stale takeover must restore the preserved OAuth auth from backup\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"http://127.0.0.1:15721/v1\"),\n \"stale enabled takeover must be rebuilt to the current proxy base_url\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"rebuilt takeover should keep the proxy bearer placeholder\"\n );\n assert!(\n service\n .live_takeover_matches_current_proxy(&AppType::Codex)\n .await\n .expect(\"detect rebuilt Codex takeover\"),\n \"rebuilt Codex live config should match the active proxy address\"\n );\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get Codex live backup\")\n .expect(\"backup exists\");\n let backup_value: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup\");\n assert_eq!(\n backup_value.get(\"auth\"),\n Some(&oauth_auth),\n \"rebuilding stale takeover must not overwrite the original OAuth backup\"\n );\n assert!(\n backup_value\n .get(\"config\")\n .and_then(|value| value.as_str())\n .is_some_and(|config| config.contains(\"deepseek-key\")\n && !config.contains(\"http://127.0.0.1\")),\n \"backup should remain the restorable DeepSeek config, not the proxy config\"\n );\n\n service\n .set_takeover_for_app(\"codex\", false)\n .await\n .expect(\"disable Codex takeover\");\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserve_disabled_uses_legacy_auth_write_path() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"cn_official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth\n .get(\"OPENAI_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER),\n \"disabled preservation should keep the legacy auth.json takeover placeholder\"\n );\n assert_eq!(\n live_auth\n .get(\"tokens\")\n .and_then(|tokens| tokens.get(\"access_token\"))\n .and_then(|value| value.as_str()),\n Some(\"oauth-access\"),\n \"the new config-only takeover branch must not run when preservation is disabled\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"disabled preservation should not move the takeover placeholder into config.toml\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n #[serial]\n fn codex_takeover_cleanup_removes_config_placeholder_without_touching_oauth_auth() {\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 service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\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 ),\n )\n .expect(\"seed taken-over Codex live config\");\n\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"config.toml placeholder should be detected before cleanup\"\n );\n\n service\n .cleanup_codex_takeover_placeholders_in_live()\n .expect(\"cleanup Codex takeover placeholders\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"cleanup should preserve ChatGPT OAuth auth\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"cleanup should remove config.toml proxy bearer placeholder\"\n );\n assert!(\n !live_config.contains(\"http://127.0.0.1:15721\"),\n \"cleanup should remove local proxy base_url\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_auth = json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n let takeover_settings = json!({\n \"auth\": takeover_auth,\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth,\n json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n }),\n \"disabled preservation should let third-party switches overwrite auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(\"experimental_bearer_token\"),\n \"provider token should stay in auth.json when preservation is disabled\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-8[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider-derived restore backup should preserve the provider's model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"custom\").is_none());\n assert_eq!(\n backup_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 \"provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"restored Codex live config should preserve the provider's model_provider\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_uses_upstream_model_without_changing_live_provider() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"stable\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_refreshes_catalog_and_common_config() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n db.set_config_snippet(\n \"codex\",\n Some(\n r#\"[mcp_servers.shared]\ncommand = \"shared-command\"\n\"#\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect(\"provider switch to provider b\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n assert!(\n catalog_path.exists(),\n \"cc-switch-model-catalog.json must be created on provider switch\"\n );\n let catalog_text = std::fs::read_to_string(&catalog_path).expect(\"read catalog json\");\n let catalog: serde_json::Value =\n serde_json::from_str(&catalog_text).expect(\"parse catalog json\");\n let slugs: Vec<&str> = catalog\n .get(\"models\")\n .and_then(|m| m.as_array())\n .map(|arr| {\n arr.iter()\n .filter_map(|e| e.get(\"slug\").and_then(|s| s.as_str()))\n .collect()\n })\n .unwrap_or_default();\n assert!(\n slugs.contains(&\"model-b\"),\n \"catalog must contain provider B's model after switch; got: {slugs:?}\"\n );\n assert!(\n !slugs.contains(&\"model-a\"),\n \"catalog must not contain stale provider A model after switch; got: {slugs:?}\"\n );\n\n let config_path = crate::codex_config::get_codex_config_path();\n let config_text = std::fs::read_to_string(&config_path).expect(\"read config.toml\");\n assert!(\n config_text.contains(\"model_catalog_json\"),\n \"config.toml must reference model_catalog_json after switch\"\n );\n assert!(\n config_text.contains(\"[mcp_servers.shared]\"),\n \"config.toml must keep common config after switch\"\n );\n assert!(\n config_text.contains(r#\"command = \"shared-command\"\"#),\n \"config.toml must include common config content after switch\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_propagates_catalog_write_errors() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n if catalog_path.exists() {\n std::fs::remove_file(&catalog_path).expect(\"remove catalog file\");\n }\n std::fs::create_dir_all(&catalog_path).expect(\"turn catalog path into directory\");\n\n let err = crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect_err(\"provider switch should fail when catalog cannot be written\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let message = err.to_string();\n assert!(\n message.contains(\"写入 Codex 配置失败\") || message.contains(\"原子替换失败\"),\n \"switch should surface catalog write failure, got: {message}\"\n );\n }\n\n /// Regression: turning proxy takeover off restores Live from the backup. The\n /// backup snapshot is `read_codex_live_settings()` output (`{auth, config}`,\n /// never an inline `modelCatalog`). The restore must NOT route the config\n /// through catalog projection, which would see no specs and strip the\n /// `model_catalog_json` pointer — silently dropping the user's Codex model\n /// mapping from Live even though the DB SSOT still holds it.\n #[tokio::test]\n #[serial]\n async fn codex_restore_from_backup_preserves_model_catalog_pointer() {\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 service = ProxyService::new(db.clone());\n\n // Pre-takeover Live state: config.toml points at the cc-switch generated\n // catalog file, and that file exists on disk (takeover never touches it).\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n if let Some(parent) = catalog_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create codex dir\");\n }\n std::fs::write(\n &catalog_path,\n r#\"{\"models\":[{\"slug\":\"deepseek-v4-flash\"}]}\"#,\n )\n .expect(\"seed generated catalog file\");\n\n let pointer = catalog_path.to_string_lossy().to_string();\n let backup_config = format!(\n \"model_provider = \\\"custom\\\"\\n\\\n model = \\\"deepseek-v4-flash\\\"\\n\\\n model_catalog_json = \\\"{pointer}\\\"\\n\\n\\\n [model_providers.custom]\\n\\\n name = \\\"DeepSeek\\\"\\n\\\n base_url = \\\"https://api.deepseek.example/v1\\\"\\n\\\n wire_api = \\\"responses\\\"\\n\"\n );\n let backup_json = serde_json::to_string(&json!({\n \"auth\": { \"OPENAI_API_KEY\": \"deepseek-key\" },\n \"config\": backup_config,\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n // Turning takeover off restores Live from this backup.\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"restore must preserve the model_catalog_json pointer, got:\\n{restored}\"\n );\n assert!(\n restored.contains(pointer.as_str()),\n \"restored pointer must still reference the cc-switch generated catalog file\"\n );\n }\n\n /// Regression: a hot-switch during takeover rebuilds the backup from the DB\n /// provider (`update_live_backup_from_provider`), so the backup carries an\n /// inline `modelCatalog` (DB SSOT) but a `config.toml` text WITHOUT a\n /// `model_catalog_json` pointer. Restoring that backup must project the\n /// inline catalog — (re)generating both the catalog file and the pointer —\n /// or the Codex model mapping vanishes from Live after takeover-off.\n #[tokio::test]\n #[serial]\n async fn codex_restore_from_backup_projects_inline_model_catalog() {\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 service = ProxyService::new(db.clone());\n\n // Catalog projection needs a model template; seed `models_cache.json`\n // with the template slug so we don't depend on the `codex` CLI.\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n r#\"{\"models\":[{\"slug\":\"gpt-5.5\"}]}\"#,\n )\n .expect(\"seed models_cache template\");\n\n // Provider-rebuilt backup shape: inline modelCatalog, pointer-less config.\n let backup_json = serde_json::to_string(&json!({\n \"auth\": { \"OPENAI_API_KEY\": \"deepseek-key\" },\n \"config\": \"model_provider = \\\"custom\\\"\\nmodel = \\\"deepseek-v4-flash\\\"\\n\\n[model_providers.custom]\\nname = \\\"DeepSeek\\\"\\nbase_url = \\\"https://api.deepseek.example/v1\\\"\\nwire_api = \\\"responses\\\"\\n\",\n \"modelCatalog\": {\n \"models\": [\n { \"model\": \"deepseek-v4-flash\", \"displayName\": \"DeepSeek V4 Flash\", \"contextWindow\": 1_000_000 }\n ]\n }\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"restore must (re)generate the model_catalog_json pointer from inline catalog, got:\\n{restored}\"\n );\n assert!(\n catalog_path.exists(),\n \"restore must generate the cc-switch catalog file on disk\"\n );\n let catalog: Value = serde_json::from_str(\n &std::fs::read_to_string(&catalog_path).expect(\"read generated catalog\"),\n )\n .expect(\"parse generated catalog\");\n let slugs: Vec<&str> = catalog\n .get(\"models\")\n .and_then(|m| m.as_array())\n .expect(\"catalog models\")\n .iter()\n .filter_map(|m| m.get(\"slug\").and_then(|s| s.as_str()))\n .collect();\n assert!(\n slugs.contains(&\"deepseek-v4-flash\"),\n \"generated catalog must contain the inline model, got slugs: {slugs:?}\"\n );\n }\n\n /// Regression: a provider-rebuilt backup can pair an inline `modelCatalog`\n /// with EMPTY `auth.json` (`{}`) — the bearer-token / Mobile-compat shape\n /// where the API key lives in the config's `experimental_bearer_token`. The\n /// empty-auth restore branch deletes `auth.json` and writes config raw; it\n /// must still project the inline catalog (decision is orthogonal to auth), or\n /// the model mapping vanishes on takeover-off for this provider shape.\n #[tokio::test]\n #[serial]\n async fn codex_restore_empty_auth_backup_still_projects_inline_catalog() {\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 service = ProxyService::new(db.clone());\n\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n r#\"{\"models\":[{\"slug\":\"gpt-5.5\"}]}\"#,\n )\n .expect(\"seed models_cache template\");\n\n // Empty auth.json + key carried in config.toml's experimental_bearer_token,\n // plus the inline modelCatalog (DB SSOT).\n let backup_json = serde_json::to_string(&json!({\n \"auth\": {},\n \"config\": \"model_provider = \\\"custom\\\"\\nmodel = \\\"deepseek-v4-flash\\\"\\n\\n[model_providers.custom]\\nname = \\\"DeepSeek\\\"\\nbase_url = \\\"https://api.deepseek.example/v1\\\"\\nwire_api = \\\"responses\\\"\\nexperimental_bearer_token = \\\"sk-deepseek\\\"\\n\",\n \"modelCatalog\": {\n \"models\": [ { \"model\": \"deepseek-v4-flash\", \"displayName\": \"DeepSeek V4 Flash\" } ]\n }\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"empty-auth restore must still project the inline catalog pointer, got:\\n{restored}\"\n );\n assert!(\n crate::codex_config::get_codex_model_catalog_path().exists(),\n \"empty-auth restore must generate the cc-switch catalog file\"\n );\n assert!(\n !crate::codex_config::get_codex_auth_path().exists(),\n \"empty-auth restore must delete auth.json rather than write an empty one\"\n );\n }\n}\n"}, "ce993baefaf01b8861f9b14fdfa8c4773f0efc0e": {"t": 1785761123.03103, "n": 175022, "h": "91503e6322cae158", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-8\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。\n let takeover_model_fields = if provider.uses_managed_account_auth() {\n Self::build_claude_takeover_model_fields(&provider.settings_config)\n } else {\n Self::build_claude_takeover_model_fields(config)\n };\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy_and_models(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n takeover_model_fields: Vec<(&'static str, String)>,\n ) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n fn claude_provider_with_effective_settings(\n &self,\n provider: &Provider,\n ) -> Result<Provider, String> {\n let mut effective_provider = provider.clone();\n effective_provider.settings_config = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n Ok(effective_provider)\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let effective_provider = self.claude_provider_with_effective_settings(provider)?;\n let mut effective_settings = effective_provider.settings_config.clone();\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n &effective_provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = match self.read_codex_live() {\n Ok(config) => config,\n Err(_) => build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?,\n };\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_takeover_live_for_provider(&effective_settings, Some(provider))?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_takeover_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider =\n self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?;\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(&codex_provider),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n Some(&codex_provider),\n );\n\n self.write_codex_takeover_live_for_provider(&live_config, Some(&codex_provider))?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n let provider = self.claude_provider_with_effective_settings(provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ = self.write_codex_takeover_live_for_provider(\n &live_config,\n codex_provider.as_ref(),\n );\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n let updated =\n crate::codex_config::remove_codex_experimental_bearer_token_if(&updated, |token| {\n token == PROXY_TOKEN_PLACEHOLDER\n })\n .map_err(|e| format!(\"清理 Codex 接管占位符失败: {e}\"))?;\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n if config\n .get(\"auth\")\n .and_then(|v| v.as_object())\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n return true;\n }\n\n config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .and_then(crate::codex_config::extract_codex_experimental_bearer_token)\n .as_deref()\n == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if has_backup && !live_taken_over && matches!(app_type_enum, AppType::Codex) {\n let effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n &provider,\n )\n .map_err(|e| format!(\"构建 Codex 有效配置失败: {e}\"))?;\n let auth = effective_settings\n .get(\"auth\")\n .ok_or_else(|| \"Codex 供应商缺少 auth 配置\".to_string())?;\n let config_str = effective_settings.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n &effective_settings,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n crate::codex_config::read_codex_live_settings()\n .map_err(|e| format!(\"读取 Codex Live 配置失败: {e}\"))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n self.write_codex_live_verbatim(config)\n }\n\n fn write_codex_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n let Some(provider) = provider else {\n if crate::settings::preserve_codex_official_auth_on_switch() {\n if let (Some(auth), Some(config_str)) = (\n config.get(\"auth\"),\n config.get(\"config\").and_then(|v| v.as_str()),\n ) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n let live_config = crate::codex_config::prepare_codex_provider_live_config(\n auth, config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n crate::codex_config::write_codex_live_config_atomic(Some(&live_config))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n return Ok(());\n }\n }\n }\n\n return self.write_codex_live_verbatim(config);\n };\n\n let auth = config\n .get(\"auth\")\n .ok_or_else(|| \"Codex 配置缺少 auth 字段\".to_string())?;\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n config,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))\n }\n\n fn codex_auth_has_proxy_placeholder(auth: &Value) -> bool {\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn write_codex_takeover_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n if crate::settings::preserve_codex_official_auth_on_switch() {\n if let Some(auth) = config\n .get(\"auth\")\n .filter(|auth| Self::codex_auth_has_proxy_placeholder(auth))\n {\n let config_str = config.get(\"config\").and_then(|v| v.as_str()).unwrap_or(\"\");\n let prepared_config =\n crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(\n config, config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n let live_config =\n crate::codex_config::prepare_codex_provider_live_config(auth, &prepared_config)\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n crate::codex_config::write_codex_live_config_atomic(Some(&live_config))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n return Ok(());\n }\n }\n\n self.write_codex_live_for_provider(config, provider)\n }\n\n fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Decide the config.toml text ONCE, before splitting on auth. A stored\n // Codex backup comes in two shapes needing opposite handling:\n // - snapshot backup (`read_codex_live_settings`): no inline `modelCatalog`;\n // the config text already carries the live `model_catalog_json` pointer\n // → keep raw, or projection would strip it.\n // - provider-rebuilt backup (`update_live_backup_from_provider`): inline\n // `modelCatalog` (DB SSOT) with a pointer-less config text → project,\n // or the mapping is lost on restore.\n // The projection decision is orthogonal to auth: a provider-rebuilt backup\n // can pair an inline `modelCatalog` with empty/absent `auth.json` (the key\n // living in the config's `experimental_bearer_token`). Computing it up here\n // keeps every config-writing branch — write-auth, delete-auth, no-auth —\n // consistent instead of letting the empty-auth path skip projection.\n let prepared_cfg = config_str\n .map(|cfg| {\n crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(\n config, cfg,\n )\n })\n .transpose()\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n\n match (auth, prepared_cfg.as_deref()) {\n (Some(auth), Some(cfg)) => {\n let auth_path = get_codex_auth_path();\n if auth.as_object().is_some_and(|obj| obj.is_empty()) {\n let _ = crate::config::delete_file(&auth_path);\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n } else {\n crate::codex_config::write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 fn assert_env_str(env: &Map<String, Value>, key: &str, expected: Option<&str>) {\n assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);\n }\n\n fn seed_codex_model_template() {\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n serde_json::to_string(&serde_json::json!({\n \"models\": [{\n \"slug\": \"gpt-5.5\",\n \"display_name\": \"GPT-5.5\",\n \"model_messages\": { \"instructions_template\": \"t\" },\n \"additional_speed_tiers\": [],\n \"context_window\": 128000\n }]\n }))\n .expect(\"serialize models_cache\"),\n )\n .expect(\"write models_cache.json\");\n }\n\n #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_copilot_models_from_provider() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.5\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-sonnet-4.6\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_API_KEY\": \"stale-key\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"claude-haiku-4.5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_codex_models_from_provider() {\n let mut provider = Provider::with_id(\n \"codex\".to_string(),\n \"Codex\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://chatgpt.com/backend-api/codex\",\n \"ANTHROPIC_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"gpt-5.4-mini\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"gpt-5.4\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"codex_oauth\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale-token\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"gpt-5.4-mini\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_preserves_oauth_auth_json() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_settings = json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"third-party Codex proxy writes must not overwrite ChatGPT OAuth login state\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"experimental_bearer_token\"),\n \"proxy placeholder should move into config.toml instead of auth.json\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"live config should carry the proxy placeholder token\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserves_oauth_auth_json_when_preserve_enabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"cn_official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"Codex takeover should not overwrite ChatGPT OAuth auth when preservation is enabled\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"takeover placeholder should move into config.toml\"\n );\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"Codex takeover detection should recognize config.toml placeholders\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserves_oauth_auth_json_even_when_provider_category_is_official() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save misclassified DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"Codex takeover must not rewrite auth.json when preservation is enabled, even if provider category is stale or misclassified\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"takeover placeholder should move into config.toml\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserve_disabled_uses_legacy_auth_write_path() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"cn_official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth\n .get(\"OPENAI_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER),\n \"disabled preservation should keep the legacy auth.json takeover placeholder\"\n );\n assert_eq!(\n live_auth\n .get(\"tokens\")\n .and_then(|tokens| tokens.get(\"access_token\"))\n .and_then(|value| value.as_str()),\n Some(\"oauth-access\"),\n \"the new config-only takeover branch must not run when preservation is disabled\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"disabled preservation should not move the takeover placeholder into config.toml\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n #[serial]\n fn codex_takeover_cleanup_removes_config_placeholder_without_touching_oauth_auth() {\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 service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\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 ),\n )\n .expect(\"seed taken-over Codex live config\");\n\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"config.toml placeholder should be detected before cleanup\"\n );\n\n service\n .cleanup_codex_takeover_placeholders_in_live()\n .expect(\"cleanup Codex takeover placeholders\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"cleanup should preserve ChatGPT OAuth auth\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"cleanup should remove config.toml proxy bearer placeholder\"\n );\n assert!(\n !live_config.contains(\"http://127.0.0.1:15721\"),\n \"cleanup should remove local proxy base_url\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_auth = json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n let takeover_settings = json!({\n \"auth\": takeover_auth,\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth,\n json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n }),\n \"disabled preservation should let third-party switches overwrite auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(\"experimental_bearer_token\"),\n \"provider token should stay in auth.json when preservation is disabled\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-8[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider-derived restore backup should preserve the provider's model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"custom\").is_none());\n assert_eq!(\n backup_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 \"provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"restored Codex live config should preserve the provider's model_provider\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_uses_upstream_model_without_changing_live_provider() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"stable\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_refreshes_catalog_and_common_config() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n db.set_config_snippet(\n \"codex\",\n Some(\n r#\"[mcp_servers.shared]\ncommand = \"shared-command\"\n\"#\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect(\"provider switch to provider b\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n assert!(\n catalog_path.exists(),\n \"cc-switch-model-catalog.json must be created on provider switch\"\n );\n let catalog_text = std::fs::read_to_string(&catalog_path).expect(\"read catalog json\");\n let catalog: serde_json::Value =\n serde_json::from_str(&catalog_text).expect(\"parse catalog json\");\n let slugs: Vec<&str> = catalog\n .get(\"models\")\n .and_then(|m| m.as_array())\n .map(|arr| {\n arr.iter()\n .filter_map(|e| e.get(\"slug\").and_then(|s| s.as_str()))\n .collect()\n })\n .unwrap_or_default();\n assert!(\n slugs.contains(&\"model-b\"),\n \"catalog must contain provider B's model after switch; got: {slugs:?}\"\n );\n assert!(\n !slugs.contains(&\"model-a\"),\n \"catalog must not contain stale provider A model after switch; got: {slugs:?}\"\n );\n\n let config_path = crate::codex_config::get_codex_config_path();\n let config_text = std::fs::read_to_string(&config_path).expect(\"read config.toml\");\n assert!(\n config_text.contains(\"model_catalog_json\"),\n \"config.toml must reference model_catalog_json after switch\"\n );\n assert!(\n config_text.contains(\"[mcp_servers.shared]\"),\n \"config.toml must keep common config after switch\"\n );\n assert!(\n config_text.contains(r#\"command = \"shared-command\"\"#),\n \"config.toml must include common config content after switch\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_propagates_catalog_write_errors() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n if catalog_path.exists() {\n std::fs::remove_file(&catalog_path).expect(\"remove catalog file\");\n }\n std::fs::create_dir_all(&catalog_path).expect(\"turn catalog path into directory\");\n\n let err = crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect_err(\"provider switch should fail when catalog cannot be written\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let message = err.to_string();\n assert!(\n message.contains(\"写入 Codex 配置失败\") || message.contains(\"原子替换失败\"),\n \"switch should surface catalog write failure, got: {message}\"\n );\n }\n\n /// Regression: turning proxy takeover off restores Live from the backup. The\n /// backup snapshot is `read_codex_live_settings()` output (`{auth, config}`,\n /// never an inline `modelCatalog`). The restore must NOT route the config\n /// through catalog projection, which would see no specs and strip the\n /// `model_catalog_json` pointer — silently dropping the user's Codex model\n /// mapping from Live even though the DB SSOT still holds it.\n #[tokio::test]\n #[serial]\n async fn codex_restore_from_backup_preserves_model_catalog_pointer() {\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 service = ProxyService::new(db.clone());\n\n // Pre-takeover Live state: config.toml points at the cc-switch generated\n // catalog file, and that file exists on disk (takeover never touches it).\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n if let Some(parent) = catalog_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create codex dir\");\n }\n std::fs::write(\n &catalog_path,\n r#\"{\"models\":[{\"slug\":\"deepseek-v4-flash\"}]}\"#,\n )\n .expect(\"seed generated catalog file\");\n\n let pointer = catalog_path.to_string_lossy().to_string();\n let backup_config = format!(\n \"model_provider = \\\"custom\\\"\\n\\\n model = \\\"deepseek-v4-flash\\\"\\n\\\n model_catalog_json = \\\"{pointer}\\\"\\n\\n\\\n [model_providers.custom]\\n\\\n name = \\\"DeepSeek\\\"\\n\\\n base_url = \\\"https://api.deepseek.example/v1\\\"\\n\\\n wire_api = \\\"responses\\\"\\n\"\n );\n let backup_json = serde_json::to_string(&json!({\n \"auth\": { \"OPENAI_API_KEY\": \"deepseek-key\" },\n \"config\": backup_config,\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n // Turning takeover off restores Live from this backup.\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"restore must preserve the model_catalog_json pointer, got:\\n{restored}\"\n );\n assert!(\n restored.contains(pointer.as_str()),\n \"restored pointer must still reference the cc-switch generated catalog file\"\n );\n }\n\n /// Regression: a hot-switch during takeover rebuilds the backup from the DB\n /// provider (`update_live_backup_from_provider`), so the backup carries an\n /// inline `modelCatalog` (DB SSOT) but a `config.toml` text WITHOUT a\n /// `model_catalog_json` pointer. Restoring that backup must project the\n /// inline catalog — (re)generating both the catalog file and the pointer —\n /// or the Codex model mapping vanishes from Live after takeover-off.\n #[tokio::test]\n #[serial]\n async fn codex_restore_from_backup_projects_inline_model_catalog() {\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 service = ProxyService::new(db.clone());\n\n // Catalog projection needs a model template; seed `models_cache.json`\n // with the template slug so we don't depend on the `codex` CLI.\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n r#\"{\"models\":[{\"slug\":\"gpt-5.5\"}]}\"#,\n )\n .expect(\"seed models_cache template\");\n\n // Provider-rebuilt backup shape: inline modelCatalog, pointer-less config.\n let backup_json = serde_json::to_string(&json!({\n \"auth\": { \"OPENAI_API_KEY\": \"deepseek-key\" },\n \"config\": \"model_provider = \\\"custom\\\"\\nmodel = \\\"deepseek-v4-flash\\\"\\n\\n[model_providers.custom]\\nname = \\\"DeepSeek\\\"\\nbase_url = \\\"https://api.deepseek.example/v1\\\"\\nwire_api = \\\"responses\\\"\\n\",\n \"modelCatalog\": {\n \"models\": [\n { \"model\": \"deepseek-v4-flash\", \"displayName\": \"DeepSeek V4 Flash\", \"contextWindow\": 1_000_000 }\n ]\n }\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"restore must (re)generate the model_catalog_json pointer from inline catalog, got:\\n{restored}\"\n );\n assert!(\n catalog_path.exists(),\n \"restore must generate the cc-switch catalog file on disk\"\n );\n let catalog: Value = serde_json::from_str(\n &std::fs::read_to_string(&catalog_path).expect(\"read generated catalog\"),\n )\n .expect(\"parse generated catalog\");\n let slugs: Vec<&str> = catalog\n .get(\"models\")\n .and_then(|m| m.as_array())\n .expect(\"catalog models\")\n .iter()\n .filter_map(|m| m.get(\"slug\").and_then(|s| s.as_str()))\n .collect();\n assert!(\n slugs.contains(&\"deepseek-v4-flash\"),\n \"generated catalog must contain the inline model, got slugs: {slugs:?}\"\n );\n }\n\n /// Regression: a provider-rebuilt backup can pair an inline `modelCatalog`\n /// with EMPTY `auth.json` (`{}`) — the bearer-token / Mobile-compat shape\n /// where the API key lives in the config's `experimental_bearer_token`. The\n /// empty-auth restore branch deletes `auth.json` and writes config raw; it\n /// must still project the inline catalog (decision is orthogonal to auth), or\n /// the model mapping vanishes on takeover-off for this provider shape.\n #[tokio::test]\n #[serial]\n async fn codex_restore_empty_auth_backup_still_projects_inline_catalog() {\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 service = ProxyService::new(db.clone());\n\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n r#\"{\"models\":[{\"slug\":\"gpt-5.5\"}]}\"#,\n )\n .expect(\"seed models_cache template\");\n\n // Empty auth.json + key carried in config.toml's experimental_bearer_token,\n // plus the inline modelCatalog (DB SSOT).\n let backup_json = serde_json::to_string(&json!({\n \"auth\": {},\n \"config\": \"model_provider = \\\"custom\\\"\\nmodel = \\\"deepseek-v4-flash\\\"\\n\\n[model_providers.custom]\\nname = \\\"DeepSeek\\\"\\nbase_url = \\\"https://api.deepseek.example/v1\\\"\\nwire_api = \\\"responses\\\"\\nexperimental_bearer_token = \\\"sk-deepseek\\\"\\n\",\n \"modelCatalog\": {\n \"models\": [ { \"model\": \"deepseek-v4-flash\", \"displayName\": \"DeepSeek V4 Flash\" } ]\n }\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"empty-auth restore must still project the inline catalog pointer, got:\\n{restored}\"\n );\n assert!(\n crate::codex_config::get_codex_model_catalog_path().exists(),\n \"empty-auth restore must generate the cc-switch catalog file\"\n );\n assert!(\n !crate::codex_config::get_codex_auth_path().exists(),\n \"empty-auth restore must delete auth.json rather than write an empty one\"\n );\n }\n}\n"}, "d5328e52907852e4d3c636e588aafa3e4b922267": {"t": 1785761124.6482584, "n": 167171, "h": "0dee24759f6e732f", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-8\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。\n let takeover_model_fields = if provider.uses_managed_account_auth() {\n Self::build_claude_takeover_model_fields(&provider.settings_config)\n } else {\n Self::build_claude_takeover_model_fields(config)\n };\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy_and_models(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n takeover_model_fields: Vec<(&'static str, String)>,\n ) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n fn claude_provider_with_effective_settings(\n &self,\n provider: &Provider,\n ) -> Result<Provider, String> {\n let mut effective_provider = provider.clone();\n effective_provider.settings_config = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n Ok(effective_provider)\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let effective_provider = self.claude_provider_with_effective_settings(provider)?;\n let mut effective_settings = effective_provider.settings_config.clone();\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n &effective_provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = match self.read_codex_live() {\n Ok(config) => config,\n Err(_) => build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?,\n };\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_live_for_provider(&effective_settings, Some(provider))?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider =\n self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?;\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(&codex_provider),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n Some(&codex_provider),\n );\n\n self.write_codex_live_for_provider(&live_config, Some(&codex_provider))?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n let provider = self.claude_provider_with_effective_settings(provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ =\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref());\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n let updated =\n crate::codex_config::remove_codex_experimental_bearer_token_if(&updated, |token| {\n token == PROXY_TOKEN_PLACEHOLDER\n })\n .map_err(|e| format!(\"清理 Codex 接管占位符失败: {e}\"))?;\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n if config\n .get(\"auth\")\n .and_then(|v| v.as_object())\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n return true;\n }\n\n config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .and_then(crate::codex_config::extract_codex_experimental_bearer_token)\n .as_deref()\n == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if has_backup && !live_taken_over && matches!(app_type_enum, AppType::Codex) {\n let effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n &provider,\n )\n .map_err(|e| format!(\"构建 Codex 有效配置失败: {e}\"))?;\n let auth = effective_settings\n .get(\"auth\")\n .ok_or_else(|| \"Codex 供应商缺少 auth 配置\".to_string())?;\n let config_str = effective_settings.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n &effective_settings,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n crate::codex_config::read_codex_live_settings()\n .map_err(|e| format!(\"读取 Codex Live 配置失败: {e}\"))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n self.write_codex_live_verbatim(config)\n }\n\n fn write_codex_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n let Some(provider) = provider else {\n if crate::settings::preserve_codex_official_auth_on_switch() {\n if let (Some(auth), Some(config_str)) = (\n config.get(\"auth\"),\n config.get(\"config\").and_then(|v| v.as_str()),\n ) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n let live_config = crate::codex_config::prepare_codex_provider_live_config(\n auth, config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n crate::codex_config::write_codex_live_config_atomic(Some(&live_config))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n return Ok(());\n }\n }\n }\n\n return self.write_codex_live_verbatim(config);\n };\n\n let auth = config\n .get(\"auth\")\n .ok_or_else(|| \"Codex 配置缺少 auth 字段\".to_string())?;\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n config,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))\n }\n\n fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Decide the config.toml text ONCE, before splitting on auth. A stored\n // Codex backup comes in two shapes needing opposite handling:\n // - snapshot backup (`read_codex_live_settings`): no inline `modelCatalog`;\n // the config text already carries the live `model_catalog_json` pointer\n // → keep raw, or projection would strip it.\n // - provider-rebuilt backup (`update_live_backup_from_provider`): inline\n // `modelCatalog` (DB SSOT) with a pointer-less config text → project,\n // or the mapping is lost on restore.\n // The projection decision is orthogonal to auth: a provider-rebuilt backup\n // can pair an inline `modelCatalog` with empty/absent `auth.json` (the key\n // living in the config's `experimental_bearer_token`). Computing it up here\n // keeps every config-writing branch — write-auth, delete-auth, no-auth —\n // consistent instead of letting the empty-auth path skip projection.\n let prepared_cfg = config_str\n .map(|cfg| {\n crate::codex_config::prepare_codex_live_config_text_with_optional_catalog(\n config, cfg,\n )\n })\n .transpose()\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n\n match (auth, prepared_cfg.as_deref()) {\n (Some(auth), Some(cfg)) => {\n let auth_path = get_codex_auth_path();\n if auth.as_object().is_some_and(|obj| obj.is_empty()) {\n let _ = crate::config::delete_file(&auth_path);\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n } else {\n crate::codex_config::write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 fn assert_env_str(env: &Map<String, Value>, key: &str, expected: Option<&str>) {\n assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);\n }\n\n fn seed_codex_model_template() {\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n serde_json::to_string(&serde_json::json!({\n \"models\": [{\n \"slug\": \"gpt-5.5\",\n \"display_name\": \"GPT-5.5\",\n \"model_messages\": { \"instructions_template\": \"t\" },\n \"additional_speed_tiers\": [],\n \"context_window\": 128000\n }]\n }))\n .expect(\"serialize models_cache\"),\n )\n .expect(\"write models_cache.json\");\n }\n\n #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_copilot_models_from_provider() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.5\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-sonnet-4.6\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_API_KEY\": \"stale-key\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"claude-haiku-4.5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_codex_models_from_provider() {\n let mut provider = Provider::with_id(\n \"codex\".to_string(),\n \"Codex\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://chatgpt.com/backend-api/codex\",\n \"ANTHROPIC_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"gpt-5.4-mini\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"gpt-5.4\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"codex_oauth\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale-token\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"gpt-5.4-mini\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_preserves_oauth_auth_json() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_settings = json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"third-party Codex proxy writes must not overwrite ChatGPT OAuth login state\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"experimental_bearer_token\"),\n \"proxy placeholder should move into config.toml instead of auth.json\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"live config should carry the proxy placeholder token\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserves_oauth_auth_json_when_preserve_enabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"cn_official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"Codex takeover should not overwrite ChatGPT OAuth auth when preservation is enabled\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"takeover placeholder should move into config.toml\"\n );\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"Codex takeover detection should recognize config.toml placeholders\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n #[serial]\n fn codex_takeover_cleanup_removes_config_placeholder_without_touching_oauth_auth() {\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 service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\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 ),\n )\n .expect(\"seed taken-over Codex live config\");\n\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"config.toml placeholder should be detected before cleanup\"\n );\n\n service\n .cleanup_codex_takeover_placeholders_in_live()\n .expect(\"cleanup Codex takeover placeholders\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"cleanup should preserve ChatGPT OAuth auth\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"cleanup should remove config.toml proxy bearer placeholder\"\n );\n assert!(\n !live_config.contains(\"http://127.0.0.1:15721\"),\n \"cleanup should remove local proxy base_url\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_auth = json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n let takeover_settings = json!({\n \"auth\": takeover_auth,\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth,\n json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n }),\n \"disabled preservation should let third-party switches overwrite auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(\"experimental_bearer_token\"),\n \"provider token should stay in auth.json when preservation is disabled\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-8[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider-derived restore backup should preserve the provider's model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"custom\").is_none());\n assert_eq!(\n backup_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 \"provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"restored Codex live config should preserve the provider's model_provider\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_uses_upstream_model_without_changing_live_provider() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"stable\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_refreshes_catalog_and_common_config() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n db.set_config_snippet(\n \"codex\",\n Some(\n r#\"[mcp_servers.shared]\ncommand = \"shared-command\"\n\"#\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect(\"provider switch to provider b\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n assert!(\n catalog_path.exists(),\n \"cc-switch-model-catalog.json must be created on provider switch\"\n );\n let catalog_text = std::fs::read_to_string(&catalog_path).expect(\"read catalog json\");\n let catalog: serde_json::Value =\n serde_json::from_str(&catalog_text).expect(\"parse catalog json\");\n let slugs: Vec<&str> = catalog\n .get(\"models\")\n .and_then(|m| m.as_array())\n .map(|arr| {\n arr.iter()\n .filter_map(|e| e.get(\"slug\").and_then(|s| s.as_str()))\n .collect()\n })\n .unwrap_or_default();\n assert!(\n slugs.contains(&\"model-b\"),\n \"catalog must contain provider B's model after switch; got: {slugs:?}\"\n );\n assert!(\n !slugs.contains(&\"model-a\"),\n \"catalog must not contain stale provider A model after switch; got: {slugs:?}\"\n );\n\n let config_path = crate::codex_config::get_codex_config_path();\n let config_text = std::fs::read_to_string(&config_path).expect(\"read config.toml\");\n assert!(\n config_text.contains(\"model_catalog_json\"),\n \"config.toml must reference model_catalog_json after switch\"\n );\n assert!(\n config_text.contains(\"[mcp_servers.shared]\"),\n \"config.toml must keep common config after switch\"\n );\n assert!(\n config_text.contains(r#\"command = \"shared-command\"\"#),\n \"config.toml must include common config content after switch\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_propagates_catalog_write_errors() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n if catalog_path.exists() {\n std::fs::remove_file(&catalog_path).expect(\"remove catalog file\");\n }\n std::fs::create_dir_all(&catalog_path).expect(\"turn catalog path into directory\");\n\n let err = crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect_err(\"provider switch should fail when catalog cannot be written\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let message = err.to_string();\n assert!(\n message.contains(\"写入 Codex 配置失败\") || message.contains(\"原子替换失败\"),\n \"switch should surface catalog write failure, got: {message}\"\n );\n }\n\n /// Regression: turning proxy takeover off restores Live from the backup. The\n /// backup snapshot is `read_codex_live_settings()` output (`{auth, config}`,\n /// never an inline `modelCatalog`). The restore must NOT route the config\n /// through catalog projection, which would see no specs and strip the\n /// `model_catalog_json` pointer — silently dropping the user's Codex model\n /// mapping from Live even though the DB SSOT still holds it.\n #[tokio::test]\n #[serial]\n async fn codex_restore_from_backup_preserves_model_catalog_pointer() {\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 service = ProxyService::new(db.clone());\n\n // Pre-takeover Live state: config.toml points at the cc-switch generated\n // catalog file, and that file exists on disk (takeover never touches it).\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n if let Some(parent) = catalog_path.parent() {\n std::fs::create_dir_all(parent).expect(\"create codex dir\");\n }\n std::fs::write(\n &catalog_path,\n r#\"{\"models\":[{\"slug\":\"deepseek-v4-flash\"}]}\"#,\n )\n .expect(\"seed generated catalog file\");\n\n let pointer = catalog_path.to_string_lossy().to_string();\n let backup_config = format!(\n \"model_provider = \\\"custom\\\"\\n\\\n model = \\\"deepseek-v4-flash\\\"\\n\\\n model_catalog_json = \\\"{pointer}\\\"\\n\\n\\\n [model_providers.custom]\\n\\\n name = \\\"DeepSeek\\\"\\n\\\n base_url = \\\"https://api.deepseek.example/v1\\\"\\n\\\n wire_api = \\\"responses\\\"\\n\"\n );\n let backup_json = serde_json::to_string(&json!({\n \"auth\": { \"OPENAI_API_KEY\": \"deepseek-key\" },\n \"config\": backup_config,\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n // Turning takeover off restores Live from this backup.\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"restore must preserve the model_catalog_json pointer, got:\\n{restored}\"\n );\n assert!(\n restored.contains(pointer.as_str()),\n \"restored pointer must still reference the cc-switch generated catalog file\"\n );\n }\n\n /// Regression: a hot-switch during takeover rebuilds the backup from the DB\n /// provider (`update_live_backup_from_provider`), so the backup carries an\n /// inline `modelCatalog` (DB SSOT) but a `config.toml` text WITHOUT a\n /// `model_catalog_json` pointer. Restoring that backup must project the\n /// inline catalog — (re)generating both the catalog file and the pointer —\n /// or the Codex model mapping vanishes from Live after takeover-off.\n #[tokio::test]\n #[serial]\n async fn codex_restore_from_backup_projects_inline_model_catalog() {\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 service = ProxyService::new(db.clone());\n\n // Catalog projection needs a model template; seed `models_cache.json`\n // with the template slug so we don't depend on the `codex` CLI.\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n r#\"{\"models\":[{\"slug\":\"gpt-5.5\"}]}\"#,\n )\n .expect(\"seed models_cache template\");\n\n // Provider-rebuilt backup shape: inline modelCatalog, pointer-less config.\n let backup_json = serde_json::to_string(&json!({\n \"auth\": { \"OPENAI_API_KEY\": \"deepseek-key\" },\n \"config\": \"model_provider = \\\"custom\\\"\\nmodel = \\\"deepseek-v4-flash\\\"\\n\\n[model_providers.custom]\\nname = \\\"DeepSeek\\\"\\nbase_url = \\\"https://api.deepseek.example/v1\\\"\\nwire_api = \\\"responses\\\"\\n\",\n \"modelCatalog\": {\n \"models\": [\n { \"model\": \"deepseek-v4-flash\", \"displayName\": \"DeepSeek V4 Flash\", \"contextWindow\": 1_000_000 }\n ]\n }\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"restore must (re)generate the model_catalog_json pointer from inline catalog, got:\\n{restored}\"\n );\n assert!(\n catalog_path.exists(),\n \"restore must generate the cc-switch catalog file on disk\"\n );\n let catalog: Value = serde_json::from_str(\n &std::fs::read_to_string(&catalog_path).expect(\"read generated catalog\"),\n )\n .expect(\"parse generated catalog\");\n let slugs: Vec<&str> = catalog\n .get(\"models\")\n .and_then(|m| m.as_array())\n .expect(\"catalog models\")\n .iter()\n .filter_map(|m| m.get(\"slug\").and_then(|s| s.as_str()))\n .collect();\n assert!(\n slugs.contains(&\"deepseek-v4-flash\"),\n \"generated catalog must contain the inline model, got slugs: {slugs:?}\"\n );\n }\n\n /// Regression: a provider-rebuilt backup can pair an inline `modelCatalog`\n /// with EMPTY `auth.json` (`{}`) — the bearer-token / Mobile-compat shape\n /// where the API key lives in the config's `experimental_bearer_token`. The\n /// empty-auth restore branch deletes `auth.json` and writes config raw; it\n /// must still project the inline catalog (decision is orthogonal to auth), or\n /// the model mapping vanishes on takeover-off for this provider shape.\n #[tokio::test]\n #[serial]\n async fn codex_restore_empty_auth_backup_still_projects_inline_catalog() {\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 service = ProxyService::new(db.clone());\n\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n r#\"{\"models\":[{\"slug\":\"gpt-5.5\"}]}\"#,\n )\n .expect(\"seed models_cache template\");\n\n // Empty auth.json + key carried in config.toml's experimental_bearer_token,\n // plus the inline modelCatalog (DB SSOT).\n let backup_json = serde_json::to_string(&json!({\n \"auth\": {},\n \"config\": \"model_provider = \\\"custom\\\"\\nmodel = \\\"deepseek-v4-flash\\\"\\n\\n[model_providers.custom]\\nname = \\\"DeepSeek\\\"\\nbase_url = \\\"https://api.deepseek.example/v1\\\"\\nwire_api = \\\"responses\\\"\\nexperimental_bearer_token = \\\"sk-deepseek\\\"\\n\",\n \"modelCatalog\": {\n \"models\": [ { \"model\": \"deepseek-v4-flash\", \"displayName\": \"DeepSeek V4 Flash\" } ]\n }\n }))\n .expect(\"serialize backup\");\n db.save_live_backup(\"codex\", &backup_json)\n .await\n .expect(\"seed live backup\");\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore codex live from backup\");\n\n let restored = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read restored config.toml\");\n assert!(\n restored.contains(\"model_catalog_json\"),\n \"empty-auth restore must still project the inline catalog pointer, got:\\n{restored}\"\n );\n assert!(\n crate::codex_config::get_codex_model_catalog_path().exists(),\n \"empty-auth restore must generate the cc-switch catalog file\"\n );\n assert!(\n !crate::codex_config::get_codex_auth_path().exists(),\n \"empty-auth restore must delete auth.json rather than write an empty one\"\n );\n }\n}\n"}, "8bf166023772ccc74819354607b084cfabbc12eb": {"t": 1785761130.047199, "n": 156916, "h": "e73f04a60d10331c", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-8\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。\n let takeover_model_fields = if provider.uses_managed_account_auth() {\n Self::build_claude_takeover_model_fields(&provider.settings_config)\n } else {\n Self::build_claude_takeover_model_fields(config)\n };\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy_and_models(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n takeover_model_fields: Vec<(&'static str, String)>,\n ) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n fn claude_provider_with_effective_settings(\n &self,\n provider: &Provider,\n ) -> Result<Provider, String> {\n let mut effective_provider = provider.clone();\n effective_provider.settings_config = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n Ok(effective_provider)\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let effective_provider = self.claude_provider_with_effective_settings(provider)?;\n let mut effective_settings = effective_provider.settings_config.clone();\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n &effective_provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = match self.read_codex_live() {\n Ok(config) => config,\n Err(_) => build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?,\n };\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_live_for_provider(&effective_settings, Some(provider))?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider =\n self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?;\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(&codex_provider),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n Some(&codex_provider),\n );\n\n self.write_codex_live_for_provider(&live_config, Some(&codex_provider))?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n let provider = self.claude_provider_with_effective_settings(provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ =\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref());\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n let updated =\n crate::codex_config::remove_codex_experimental_bearer_token_if(&updated, |token| {\n token == PROXY_TOKEN_PLACEHOLDER\n })\n .map_err(|e| format!(\"清理 Codex 接管占位符失败: {e}\"))?;\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n if config\n .get(\"auth\")\n .and_then(|v| v.as_object())\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n return true;\n }\n\n config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .and_then(crate::codex_config::extract_codex_experimental_bearer_token)\n .as_deref()\n == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if has_backup && !live_taken_over && matches!(app_type_enum, AppType::Codex) {\n let effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n &provider,\n )\n .map_err(|e| format!(\"构建 Codex 有效配置失败: {e}\"))?;\n let auth = effective_settings\n .get(\"auth\")\n .ok_or_else(|| \"Codex 供应商缺少 auth 配置\".to_string())?;\n let config_str = effective_settings.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n &effective_settings,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n crate::codex_config::read_codex_live_settings()\n .map_err(|e| format!(\"读取 Codex Live 配置失败: {e}\"))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n self.write_codex_live_verbatim(config)\n }\n\n fn write_codex_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n let Some(provider) = provider else {\n if crate::settings::preserve_codex_official_auth_on_switch() {\n if let (Some(auth), Some(config_str)) = (\n config.get(\"auth\"),\n config.get(\"config\").and_then(|v| v.as_str()),\n ) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n let live_config = crate::codex_config::prepare_codex_provider_live_config(\n auth, config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n crate::codex_config::write_codex_live_config_atomic(Some(&live_config))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n return Ok(());\n }\n }\n }\n\n return self.write_codex_live_verbatim(config);\n };\n\n let auth = config\n .get(\"auth\")\n .ok_or_else(|| \"Codex 配置缺少 auth 字段\".to_string())?;\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n config,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))\n }\n\n fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => {\n let auth_path = get_codex_auth_path();\n if auth.as_object().is_some_and(|obj| obj.is_empty()) {\n let _ = crate::config::delete_file(&auth_path);\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n } else {\n crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 fn assert_env_str(env: &Map<String, Value>, key: &str, expected: Option<&str>) {\n assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);\n }\n\n fn seed_codex_model_template() {\n let codex_dir = crate::codex_config::get_codex_config_dir();\n std::fs::create_dir_all(&codex_dir).expect(\"create codex dir\");\n std::fs::write(\n codex_dir.join(\"models_cache.json\"),\n serde_json::to_string(&serde_json::json!({\n \"models\": [{\n \"slug\": \"gpt-5.5\",\n \"display_name\": \"GPT-5.5\",\n \"model_messages\": { \"instructions_template\": \"t\" },\n \"additional_speed_tiers\": [],\n \"context_window\": 128000\n }]\n }))\n .expect(\"serialize models_cache\"),\n )\n .expect(\"write models_cache.json\");\n }\n\n #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_copilot_models_from_provider() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.5\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-sonnet-4.6\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_API_KEY\": \"stale-key\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"claude-haiku-4.5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_codex_models_from_provider() {\n let mut provider = Provider::with_id(\n \"codex\".to_string(),\n \"Codex\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://chatgpt.com/backend-api/codex\",\n \"ANTHROPIC_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"gpt-5.4-mini\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"gpt-5.4\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"codex_oauth\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale-token\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"gpt-5.4-mini\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_preserves_oauth_auth_json() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_settings = json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"third-party Codex proxy writes must not overwrite ChatGPT OAuth login state\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"experimental_bearer_token\"),\n \"proxy placeholder should move into config.toml instead of auth.json\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"live config should carry the proxy placeholder token\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserves_oauth_auth_json_when_preserve_enabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"cn_official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"Codex takeover should not overwrite ChatGPT OAuth auth when preservation is enabled\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"takeover placeholder should move into config.toml\"\n );\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"Codex takeover detection should recognize config.toml placeholders\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n #[serial]\n fn codex_takeover_cleanup_removes_config_placeholder_without_touching_oauth_auth() {\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 service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\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 ),\n )\n .expect(\"seed taken-over Codex live config\");\n\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"config.toml placeholder should be detected before cleanup\"\n );\n\n service\n .cleanup_codex_takeover_placeholders_in_live()\n .expect(\"cleanup Codex takeover placeholders\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"cleanup should preserve ChatGPT OAuth auth\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"cleanup should remove config.toml proxy bearer placeholder\"\n );\n assert!(\n !live_config.contains(\"http://127.0.0.1:15721\"),\n \"cleanup should remove local proxy base_url\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_auth = json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n let takeover_settings = json!({\n \"auth\": takeover_auth,\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth,\n json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n }),\n \"disabled preservation should let third-party switches overwrite auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(\"experimental_bearer_token\"),\n \"provider token should stay in auth.json when preservation is disabled\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-8[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider-derived restore backup should preserve the provider's model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"custom\").is_none());\n assert_eq!(\n backup_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 \"provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"restored Codex live config should preserve the provider's model_provider\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_uses_upstream_model_without_changing_live_provider() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"stable\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_refreshes_catalog_and_common_config() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n db.set_config_snippet(\n \"codex\",\n Some(\n r#\"[mcp_servers.shared]\ncommand = \"shared-command\"\n\"#\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect(\"provider switch to provider b\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n assert!(\n catalog_path.exists(),\n \"cc-switch-model-catalog.json must be created on provider switch\"\n );\n let catalog_text = std::fs::read_to_string(&catalog_path).expect(\"read catalog json\");\n let catalog: serde_json::Value =\n serde_json::from_str(&catalog_text).expect(\"parse catalog json\");\n let slugs: Vec<&str> = catalog\n .get(\"models\")\n .and_then(|m| m.as_array())\n .map(|arr| {\n arr.iter()\n .filter_map(|e| e.get(\"slug\").and_then(|s| s.as_str()))\n .collect()\n })\n .unwrap_or_default();\n assert!(\n slugs.contains(&\"model-b\"),\n \"catalog must contain provider B's model after switch; got: {slugs:?}\"\n );\n assert!(\n !slugs.contains(&\"model-a\"),\n \"catalog must not contain stale provider A model after switch; got: {slugs:?}\"\n );\n\n let config_path = crate::codex_config::get_codex_config_path();\n let config_text = std::fs::read_to_string(&config_path).expect(\"read config.toml\");\n assert!(\n config_text.contains(\"model_catalog_json\"),\n \"config.toml must reference model_catalog_json after switch\"\n );\n assert!(\n config_text.contains(\"[mcp_servers.shared]\"),\n \"config.toml must keep common config after switch\"\n );\n assert!(\n config_text.contains(r#\"command = \"shared-command\"\"#),\n \"config.toml must include common config content after switch\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn provider_switch_with_restored_codex_backup_propagates_catalog_write_errors() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n seed_codex_model_template();\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let state = crate::store::AppState::new(db.clone());\n\n let mut proxy_config = ProxyConfig::default();\n proxy_config.listen_port = 0;\n db.update_proxy_config(proxy_config)\n .await\n .expect(\"set test proxy config\");\n state\n .proxy_service\n .start()\n .await\n .expect(\"start proxy server\");\n\n let config_a = r#\"model_provider = \"provider-a\"\nmodel = \"model-a\"\n\n[model_providers.provider-a]\nname = \"ProviderA\"\nbase_url = \"https://provider-a.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n let config_b = r#\"model_provider = \"provider-b\"\nmodel = \"model-b\"\n\n[model_providers.provider-b]\nname = \"ProviderB\"\nbase_url = \"https://provider-b.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"ProviderA\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-a\" },\n \"config\": config_a,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-a\" }] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"ProviderB\".to_string(),\n serde_json::json!({\n \"auth\": { \"OPENAI_API_KEY\": \"key-b\" },\n \"config\": config_b,\n \"modelCatalog\": { \"models\": [{ \"model\": \"model-b\" }] }\n }),\n None,\n );\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider a\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider a\");\n\n state\n .proxy_service\n .write_codex_live_for_provider(&provider_a.settings_config, Some(&provider_a))\n .expect(\"seed live codex config\");\n assert!(\n !state\n .proxy_service\n .detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"seeded live config should not be proxy-taken-over\"\n );\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize backup\"),\n )\n .await\n .expect(\"seed restored backup\");\n\n let catalog_path = crate::codex_config::get_codex_model_catalog_path();\n if catalog_path.exists() {\n std::fs::remove_file(&catalog_path).expect(\"remove catalog file\");\n }\n std::fs::create_dir_all(&catalog_path).expect(\"turn catalog path into directory\");\n\n let err = crate::services::provider::ProviderService::switch(&state, AppType::Codex, \"b\")\n .expect_err(\"provider switch should fail when catalog cannot be written\");\n state.proxy_service.stop().await.expect(\"stop proxy server\");\n\n let message = err.to_string();\n assert!(\n message.contains(\"写入 Codex 配置失败\") || message.contains(\"原子替换失败\"),\n \"switch should surface catalog write failure, got: {message}\"\n );\n }\n}\n"}, "c9cadd6e09efdab2860aa925b9e9dab09fafbd16": {"t": 1785761133.8188972, "n": 146512, "h": "7b87ba49750e4dfa", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-8\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。\n let takeover_model_fields = if provider.uses_managed_account_auth() {\n Self::build_claude_takeover_model_fields(&provider.settings_config)\n } else {\n Self::build_claude_takeover_model_fields(config)\n };\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy_and_models(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n takeover_model_fields: Vec<(&'static str, String)>,\n ) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n fn claude_provider_with_effective_settings(\n &self,\n provider: &Provider,\n ) -> Result<Provider, String> {\n let mut effective_provider = provider.clone();\n effective_provider.settings_config = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n Ok(effective_provider)\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let effective_provider = self.claude_provider_with_effective_settings(provider)?;\n let mut effective_settings = effective_provider.settings_config.clone();\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n &effective_provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = match self.read_codex_live() {\n Ok(config) => config,\n Err(_) => build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?,\n };\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_live_for_provider(&effective_settings, Some(provider))?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider =\n self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self.require_current_provider_for_app(&AppType::Codex)?;\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(&codex_provider),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n Some(&codex_provider),\n );\n\n self.write_codex_live_for_provider(&live_config, Some(&codex_provider))?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n let provider = self.claude_provider_with_effective_settings(provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ =\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref());\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n let updated =\n crate::codex_config::remove_codex_experimental_bearer_token_if(&updated, |token| {\n token == PROXY_TOKEN_PLACEHOLDER\n })\n .map_err(|e| format!(\"清理 Codex 接管占位符失败: {e}\"))?;\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n if config\n .get(\"auth\")\n .and_then(|v| v.as_object())\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n return true;\n }\n\n config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .and_then(crate::codex_config::extract_codex_experimental_bearer_token)\n .as_deref()\n == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n crate::codex_config::read_codex_live_settings()\n .map_err(|e| format!(\"读取 Codex Live 配置失败: {e}\"))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n self.write_codex_live_verbatim(config)\n }\n\n fn write_codex_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n let Some(provider) = provider else {\n if crate::settings::preserve_codex_official_auth_on_switch() {\n if let (Some(auth), Some(config_str)) = (\n config.get(\"auth\"),\n config.get(\"config\").and_then(|v| v.as_str()),\n ) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str())\n == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n let live_config = crate::codex_config::prepare_codex_provider_live_config(\n auth, config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n crate::codex_config::write_codex_live_config_atomic(Some(&live_config))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n return Ok(());\n }\n }\n }\n\n return self.write_codex_live_verbatim(config);\n };\n\n let auth = config\n .get(\"auth\")\n .ok_or_else(|| \"Codex 配置缺少 auth 字段\".to_string())?;\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n config,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))\n }\n\n fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => {\n let auth_path = get_codex_auth_path();\n if auth.as_object().is_some_and(|obj| obj.is_empty()) {\n let _ = crate::config::delete_file(&auth_path);\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n } else {\n crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 fn assert_env_str(env: &Map<String, Value>, key: &str, expected: Option<&str>) {\n assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);\n }\n\n #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_copilot_models_from_provider() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.5\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-sonnet-4.6\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_API_KEY\": \"stale-key\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"claude-haiku-4.5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_codex_models_from_provider() {\n let mut provider = Provider::with_id(\n \"codex\".to_string(),\n \"Codex\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://chatgpt.com/backend-api/codex\",\n \"ANTHROPIC_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"gpt-5.4-mini\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"gpt-5.4\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"codex_oauth\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale-token\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"gpt-5.4-mini\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_preserves_oauth_auth_json() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_settings = json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"third-party Codex proxy writes must not overwrite ChatGPT OAuth login state\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"experimental_bearer_token\"),\n \"proxy placeholder should move into config.toml instead of auth.json\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"live config should carry the proxy placeholder token\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[tokio::test]\n #[serial]\n async fn codex_takeover_preserves_oauth_auth_json_when_preserve_enabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db.clone());\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n let deepseek_live_config = r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nexperimental_bearer_token = \"deepseek-key\"\n\"#;\n crate::codex_config::write_codex_live_atomic(&oauth_auth, Some(deepseek_live_config))\n .expect(\"seed live OAuth auth with DeepSeek config\");\n\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"cn_official\".to_string());\n db.save_provider(\"codex\", &provider)\n .expect(\"save DeepSeek provider\");\n db.set_current_provider(\"codex\", \"deepseek\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"deepseek\"))\n .expect(\"set local current provider\");\n\n service\n .takeover_live_config_strict(&AppType::Codex)\n .await\n .expect(\"take over Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"Codex takeover should not overwrite ChatGPT OAuth auth when preservation is enabled\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"takeover placeholder should move into config.toml\"\n );\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"Codex takeover detection should recognize config.toml placeholders\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n #[serial]\n fn codex_takeover_cleanup_removes_config_placeholder_without_touching_oauth_auth() {\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 service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\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 ),\n )\n .expect(\"seed taken-over Codex live config\");\n\n assert!(\n service.detect_takeover_in_live_config_for_app(&AppType::Codex),\n \"config.toml placeholder should be detected before cleanup\"\n );\n\n service\n .cleanup_codex_takeover_placeholders_in_live()\n .expect(\"cleanup Codex takeover placeholders\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"cleanup should preserve ChatGPT OAuth auth\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"cleanup should remove config.toml proxy bearer placeholder\"\n );\n assert!(\n !live_config.contains(\"http://127.0.0.1:15721\"),\n \"cleanup should remove local proxy base_url\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_auth = json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n let takeover_settings = json!({\n \"auth\": takeover_auth,\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth,\n json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n }),\n \"disabled preservation should let third-party switches overwrite auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(\"experimental_bearer_token\"),\n \"provider token should stay in auth.json when preservation is disabled\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-8[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider-derived restore backup should preserve the provider's model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"custom\").is_none());\n assert_eq!(\n backup_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 \"provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"restored Codex live config should preserve the provider's model_provider\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_uses_upstream_model_without_changing_live_provider() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"stable\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "3f59ab3746329c6a754194d6d3fa1e23878e7db2": {"t": 1785761135.4942832, "n": 140025, "h": "fd317688699c2f2d", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-8\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。\n let takeover_model_fields = if provider.uses_managed_account_auth() {\n Self::build_claude_takeover_model_fields(&provider.settings_config)\n } else {\n Self::build_claude_takeover_model_fields(config)\n };\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy_and_models(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n takeover_model_fields: Vec<(&'static str, String)>,\n ) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n fn claude_provider_with_effective_settings(\n &self,\n provider: &Provider,\n ) -> Result<Provider, String> {\n let mut effective_provider = provider.clone();\n effective_provider.settings_config = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n Ok(effective_provider)\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let effective_provider = self.claude_provider_with_effective_settings(provider)?;\n let mut effective_settings = effective_provider.settings_config.clone();\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n &effective_provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = match self.read_codex_live() {\n Ok(config) => config,\n Err(_) => build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?,\n };\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_live_for_provider(&effective_settings, Some(provider))?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider =\n self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n let provider = self.claude_provider_with_effective_settings(provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ =\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref());\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n crate::codex_config::read_codex_live_settings()\n .map_err(|e| format!(\"读取 Codex Live 配置失败: {e}\"))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n self.write_codex_live_verbatim(config)\n }\n\n fn write_codex_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n let Some(provider) = provider else {\n return self.write_codex_live_verbatim(config);\n };\n\n let auth = config\n .get(\"auth\")\n .ok_or_else(|| \"Codex 配置缺少 auth 字段\".to_string())?;\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n config,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))\n }\n\n fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => {\n let auth_path = get_codex_auth_path();\n if auth.as_object().is_some_and(|obj| obj.is_empty()) {\n let _ = crate::config::delete_file(&auth_path);\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n } else {\n crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 fn assert_env_str(env: &Map<String, Value>, key: &str, expected: Option<&str>) {\n assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);\n }\n\n #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_copilot_models_from_provider() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.5\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-sonnet-4.6\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_API_KEY\": \"stale-key\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"claude-haiku-4.5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_codex_models_from_provider() {\n let mut provider = Provider::with_id(\n \"codex\".to_string(),\n \"Codex\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://chatgpt.com/backend-api/codex\",\n \"ANTHROPIC_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"gpt-5.4-mini\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"gpt-5.4\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"codex_oauth\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale-token\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"gpt-5.4-mini\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_preserves_oauth_auth_json() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: true,\n ..Default::default()\n })\n .expect(\"enable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_settings = json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"third-party Codex proxy writes must not overwrite ChatGPT OAuth login state\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"experimental_bearer_token\"),\n \"proxy placeholder should move into config.toml instead of auth.json\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"live config should carry the proxy placeholder token\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_auth = json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n let takeover_settings = json!({\n \"auth\": takeover_auth,\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth,\n json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n }),\n \"disabled preservation should let third-party switches overwrite auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(\"experimental_bearer_token\"),\n \"provider token should stay in auth.json when preservation is disabled\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-8[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider-derived restore backup should preserve the provider's model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"custom\").is_none());\n assert_eq!(\n backup_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 \"provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"restored Codex live config should preserve the provider's model_provider\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_uses_upstream_model_without_changing_live_provider() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"stable\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "2683af57cb64e64d6d47875303aa950deee56041": {"t": 1785761138.5777965, "n": 139669, "h": "68f38cd5243e7d33", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-8\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。\n let takeover_model_fields = if provider.uses_managed_account_auth() {\n Self::build_claude_takeover_model_fields(&provider.settings_config)\n } else {\n Self::build_claude_takeover_model_fields(config)\n };\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy_and_models(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n takeover_model_fields: Vec<(&'static str, String)>,\n ) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n fn claude_provider_with_effective_settings(\n &self,\n provider: &Provider,\n ) -> Result<Provider, String> {\n let mut effective_provider = provider.clone();\n effective_provider.settings_config = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n Ok(effective_provider)\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let effective_provider = self.claude_provider_with_effective_settings(provider)?;\n let mut effective_settings = effective_provider.settings_config.clone();\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n &effective_provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = match self.read_codex_live() {\n Ok(config) => config,\n Err(_) => build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?,\n };\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_live_for_provider(&effective_settings, Some(provider))?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider =\n self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n let provider = self.claude_provider_with_effective_settings(provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ =\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref());\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n crate::codex_config::read_codex_live_settings()\n .map_err(|e| format!(\"读取 Codex Live 配置失败: {e}\"))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n self.write_codex_live_verbatim(config)\n }\n\n fn write_codex_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n let Some(provider) = provider else {\n return self.write_codex_live_verbatim(config);\n };\n\n let auth = config\n .get(\"auth\")\n .ok_or_else(|| \"Codex 配置缺少 auth 字段\".to_string())?;\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n config,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))\n }\n\n fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => {\n let auth_path = get_codex_auth_path();\n if auth.as_object().is_some_and(|obj| obj.is_empty()) {\n let _ = crate::config::delete_file(&auth_path);\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n } else {\n crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 fn assert_env_str(env: &Map<String, Value>, key: &str, expected: Option<&str>) {\n assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);\n }\n\n #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_copilot_models_from_provider() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.5\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-sonnet-4.6\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_API_KEY\": \"stale-key\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"claude-haiku-4.5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_codex_models_from_provider() {\n let mut provider = Provider::with_id(\n \"codex\".to_string(),\n \"Codex\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://chatgpt.com/backend-api/codex\",\n \"ANTHROPIC_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"gpt-5.4-mini\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"gpt-5.4\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"codex_oauth\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale-token\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"gpt-5.4-mini\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_preserves_oauth_auth_json() {\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 service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_settings = json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"third-party Codex proxy writes must not overwrite ChatGPT OAuth login state\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"experimental_bearer_token\"),\n \"proxy placeholder should move into config.toml instead of auth.json\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"live config should carry the proxy placeholder token\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_can_overwrite_auth_when_preserve_disabled() {\n let _home = TempHome::new();\n crate::settings::reload_settings().expect(\"reload settings\");\n crate::settings::update_settings(crate::settings::AppSettings {\n preserve_codex_official_auth_on_switch: false,\n ..Default::default()\n })\n .expect(\"disable Codex official auth preservation\");\n\n let db = Arc::new(Database::memory().expect(\"init db\"));\n let service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_auth = json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n let takeover_settings = json!({\n \"auth\": takeover_auth,\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth,\n json!({\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n }),\n \"disabled preservation should let third-party switches overwrite auth.json\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n !live_config.contains(\"experimental_bearer_token\"),\n \"provider token should stay in auth.json when preservation is disabled\"\n );\n\n crate::settings::update_settings(crate::settings::AppSettings::default())\n .expect(\"reset settings\");\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-8[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider-derived restore backup should preserve the provider's model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"custom\").is_none());\n assert_eq!(\n backup_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 \"provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"restored Codex live config should preserve the provider's model_provider\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_uses_upstream_model_without_changing_live_provider() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"stable\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "0877b9e35d10f1efa277a0e2d9dd1d2e78cff1dc": {"t": 1785761140.3312845, "n": 136746, "h": "d20bc154a7620d2a", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-8\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。\n let takeover_model_fields = if provider.uses_managed_account_auth() {\n Self::build_claude_takeover_model_fields(&provider.settings_config)\n } else {\n Self::build_claude_takeover_model_fields(config)\n };\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy_and_models(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n takeover_model_fields: Vec<(&'static str, String)>,\n ) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n fn claude_provider_with_effective_settings(\n &self,\n provider: &Provider,\n ) -> Result<Provider, String> {\n let mut effective_provider = provider.clone();\n effective_provider.settings_config = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n Ok(effective_provider)\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let effective_provider = self.claude_provider_with_effective_settings(provider)?;\n let mut effective_settings = effective_provider.settings_config.clone();\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n &effective_provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = match self.read_codex_live() {\n Ok(config) => config,\n Err(_) => build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?,\n };\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_live_for_provider(&effective_settings, Some(provider))?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider =\n self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n let provider = self.claude_provider_with_effective_settings(provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ =\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref());\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n crate::codex_config::read_codex_live_settings()\n .map_err(|e| format!(\"读取 Codex Live 配置失败: {e}\"))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n self.write_codex_live_verbatim(config)\n }\n\n fn write_codex_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n let Some(provider) = provider else {\n return self.write_codex_live_verbatim(config);\n };\n\n let auth = config\n .get(\"auth\")\n .ok_or_else(|| \"Codex 配置缺少 auth 字段\".to_string())?;\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n config,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))\n }\n\n fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => {\n let auth_path = get_codex_auth_path();\n if auth.as_object().is_some_and(|obj| obj.is_empty()) {\n let _ = crate::config::delete_file(&auth_path);\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n } else {\n crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 fn assert_env_str(env: &Map<String, Value>, key: &str, expected: Option<&str>) {\n assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);\n }\n\n #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_copilot_models_from_provider() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.5\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-sonnet-4.6\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_API_KEY\": \"stale-key\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"claude-haiku-4.5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_codex_models_from_provider() {\n let mut provider = Provider::with_id(\n \"codex\".to_string(),\n \"Codex\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://chatgpt.com/backend-api/codex\",\n \"ANTHROPIC_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"gpt-5.4-mini\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"gpt-5.4\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"codex_oauth\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale-token\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"gpt-5.4-mini\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-8\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_preserves_oauth_auth_json() {\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 service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_settings = json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"third-party Codex proxy writes must not overwrite ChatGPT OAuth login state\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"experimental_bearer_token\"),\n \"proxy placeholder should move into config.toml instead of auth.json\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"live config should carry the proxy placeholder token\"\n );\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-8[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider-derived restore backup should preserve the provider's model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"custom\").is_none());\n assert_eq!(\n backup_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 \"provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"restored Codex live config should preserve the provider's model_provider\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_uses_upstream_model_without_changing_live_provider() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"stable\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "a2ac21d0a63ec2da2cc44eb2a0ac8a935b26df5a": {"t": 1785761142.416664, "n": 136746, "h": "3b9d5e4e30d4a3a8", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-7\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。\n let takeover_model_fields = if provider.uses_managed_account_auth() {\n Self::build_claude_takeover_model_fields(&provider.settings_config)\n } else {\n Self::build_claude_takeover_model_fields(config)\n };\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy_and_models(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n takeover_model_fields: Vec<(&'static str, String)>,\n ) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n fn claude_provider_with_effective_settings(\n &self,\n provider: &Provider,\n ) -> Result<Provider, String> {\n let mut effective_provider = provider.clone();\n effective_provider.settings_config = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n Ok(effective_provider)\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let effective_provider = self.claude_provider_with_effective_settings(provider)?;\n let mut effective_settings = effective_provider.settings_config.clone();\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n &effective_provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = match self.read_codex_live() {\n Ok(config) => config,\n Err(_) => build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?,\n };\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_live_for_provider(&effective_settings, Some(provider))?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider =\n self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n let provider = self.claude_provider_with_effective_settings(provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ =\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref());\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n crate::codex_config::read_codex_live_settings()\n .map_err(|e| format!(\"读取 Codex Live 配置失败: {e}\"))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n self.write_codex_live_verbatim(config)\n }\n\n fn write_codex_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n let Some(provider) = provider else {\n return self.write_codex_live_verbatim(config);\n };\n\n let auth = config\n .get(\"auth\")\n .ok_or_else(|| \"Codex 配置缺少 auth 字段\".to_string())?;\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n config,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))\n }\n\n fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => {\n let auth_path = get_codex_auth_path();\n if auth.as_object().is_some_and(|obj| obj.is_empty()) {\n let _ = crate::config::delete_file(&auth_path);\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n } else {\n crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 fn assert_env_str(env: &Map<String, Value>, key: &str, expected: Option<&str>) {\n assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);\n }\n\n #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_copilot_models_from_provider() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.5\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-sonnet-4.6\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_API_KEY\": \"stale-key\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"claude-haiku-4.5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-7\"));\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_codex_models_from_provider() {\n let mut provider = Provider::with_id(\n \"codex\".to_string(),\n \"Codex\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://chatgpt.com/backend-api/codex\",\n \"ANTHROPIC_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"gpt-5.4-mini\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"gpt-5.4\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"codex_oauth\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale-token\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"gpt-5.4-mini\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-7\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_preserves_oauth_auth_json() {\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 service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_settings = json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"third-party Codex proxy writes must not overwrite ChatGPT OAuth login state\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"experimental_bearer_token\"),\n \"proxy placeholder should move into config.toml instead of auth.json\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"live config should carry the proxy placeholder token\"\n );\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-7[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"provider-derived restore backup should preserve the provider's model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"custom\").is_none());\n assert_eq!(\n backup_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 \"provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"aihubmix\"),\n \"restored Codex live config should preserve the provider's model_provider\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_uses_upstream_model_without_changing_live_provider() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"stable\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "95f2dd41262f01209100128ea647dbd054b5624a": {"t": 1785761145.1520922, "n": 136936, "h": "b898235e5b59353b", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-7\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。\n let takeover_model_fields = if provider.uses_managed_account_auth() {\n Self::build_claude_takeover_model_fields(&provider.settings_config)\n } else {\n Self::build_claude_takeover_model_fields(config)\n };\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy_and_models(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n takeover_model_fields: Vec<(&'static str, String)>,\n ) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n fn claude_provider_with_effective_settings(\n &self,\n provider: &Provider,\n ) -> Result<Provider, String> {\n let mut effective_provider = provider.clone();\n effective_provider.settings_config = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n Ok(effective_provider)\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let effective_provider = self.claude_provider_with_effective_settings(provider)?;\n let mut effective_settings = effective_provider.settings_config.clone();\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n &effective_provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = match self.read_codex_live() {\n Ok(config) => config,\n Err(_) => build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?,\n };\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_live_for_provider(&effective_settings, Some(provider))?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider =\n self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref())?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n let provider = self.claude_provider_with_effective_settings(provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ =\n self.write_codex_live_for_provider(&live_config, codex_provider.as_ref());\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n\n crate::codex_config::normalize_codex_settings_config_model_provider(\n &mut effective_settings,\n )\n .map_err(|e| format!(\"归一化 Codex restore backup 失败: {e}\"))?;\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n crate::codex_config::read_codex_live_settings()\n .map_err(|e| format!(\"读取 Codex Live 配置失败: {e}\"))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n self.write_codex_live_verbatim(config)\n }\n\n fn write_codex_live_for_provider(\n &self,\n config: &Value,\n provider: Option<&Provider>,\n ) -> Result<(), String> {\n let Some(provider) = provider else {\n return self.write_codex_live_verbatim(config);\n };\n\n let auth = config\n .get(\"auth\")\n .ok_or_else(|| \"Codex 配置缺少 auth 字段\".to_string())?;\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n crate::codex_config::write_codex_provider_live_with_catalog(\n config,\n provider.category.as_deref(),\n auth,\n config_str,\n )\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))\n }\n\n fn write_codex_live_verbatim(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => {\n let auth_path = get_codex_auth_path();\n if auth.as_object().is_some_and(|obj| obj.is_empty()) {\n let _ = crate::config::delete_file(&auth_path);\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n } else {\n crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 fn assert_env_str(env: &Map<String, Value>, key: &str, expected: Option<&str>) {\n assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);\n }\n\n #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_copilot_models_from_provider() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.5\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-sonnet-4.6\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_API_KEY\": \"stale-key\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"claude-haiku-4.5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-7\"));\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_codex_models_from_provider() {\n let mut provider = Provider::with_id(\n \"codex\".to_string(),\n \"Codex\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://chatgpt.com/backend-api/codex\",\n \"ANTHROPIC_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"gpt-5.4-mini\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"gpt-5.4\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"codex_oauth\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale-token\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"gpt-5.4-mini\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-7\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n #[serial]\n fn codex_custom_provider_live_write_preserves_oauth_auth_json() {\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 service = ProxyService::new(db);\n let oauth_auth = json!({\n \"auth_mode\": \"chatgpt\",\n \"tokens\": {\n \"id_token\": \"oauth-id\",\n \"access_token\": \"oauth-access\"\n }\n });\n crate::codex_config::write_codex_live_atomic(\n &oauth_auth,\n Some(\n r#\"model_provider = \"openai\"\nmodel = \"gpt-5-codex\"\n\"#,\n ),\n )\n .expect(\"seed live OAuth auth\");\n\n let mut provider = Provider::with_id(\n \"rightcode\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"https://rightcode.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.category = Some(\"custom\".to_string());\n let takeover_settings = json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5-codex\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\n\"#\n });\n\n service\n .write_codex_live_for_provider(&takeover_settings, Some(&provider))\n .expect(\"write provider-driven Codex live config\");\n\n let live_auth: Value =\n crate::config::read_json_file(&crate::codex_config::get_codex_auth_path())\n .expect(\"read live auth\");\n assert_eq!(\n live_auth, oauth_auth,\n \"third-party Codex proxy writes must not overwrite ChatGPT OAuth login state\"\n );\n\n let live_config = std::fs::read_to_string(crate::codex_config::get_codex_config_path())\n .expect(\"read live config\");\n assert!(\n live_config.contains(\"experimental_bearer_token\"),\n \"proxy placeholder should move into config.toml instead of auth.json\"\n );\n assert!(\n live_config.contains(PROXY_TOKEN_PLACEHOLDER),\n \"live config should carry the proxy placeholder token\"\n );\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-7[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"custom\"),\n \"provider-derived restore backup should retain stable Codex model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"aihubmix\").is_none());\n assert_eq!(\n backup_model_providers\n .get(\"custom\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"stable provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"custom\"),\n \"restored Codex live config should not switch history buckets\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_uses_upstream_model_without_changing_live_provider() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"custom\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "e25682d345db0a570a302a2c530ec4de27197f1a": {"t": 1785761146.7340415, "n": 133748, "h": "4703d7271e404e7d", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-7\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n // Copilot/Codex 接管时 live config 可能还是旧供应商;显示模型必须跟随目标 provider。\n let takeover_model_fields = if provider.uses_managed_account_auth() {\n Self::build_claude_takeover_model_fields(&provider.settings_config)\n } else {\n Self::build_claude_takeover_model_fields(config)\n };\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n Self::apply_claude_takeover_fields_with_policy_and_models(\n config,\n proxy_url,\n auth_policy,\n takeover_model_fields,\n );\n }\n\n fn apply_claude_takeover_fields_with_policy_and_models(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n takeover_model_fields: Vec<(&'static str, String)>,\n ) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n fn claude_provider_with_effective_settings(\n &self,\n provider: &Provider,\n ) -> Result<Provider, String> {\n let mut effective_provider = provider.clone();\n effective_provider.settings_config = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n Ok(effective_provider)\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let effective_provider = self.claude_provider_with_effective_settings(provider)?;\n let mut effective_settings = effective_provider.settings_config.clone();\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n &effective_provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = match self.read_codex_live() {\n Ok(config) => config,\n Err(_) => build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?,\n };\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_live(&effective_settings)?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider = self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n let claude_provider =\n self.claude_provider_with_effective_settings(&claude_provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n let provider = self.claude_provider_with_effective_settings(provider)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n\n crate::codex_config::normalize_codex_settings_config_model_provider(\n &mut effective_settings,\n )\n .map_err(|e| format!(\"归一化 Codex restore backup 失败: {e}\"))?;\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Proxy restore writes saved live backups verbatim. Provider-driven writes go\n // through write_live_with_common_config(), which normalizes Codex provider ids.\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => {\n // Use unified helper to prepare catalog if present\n crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 fn assert_env_str(env: &Map<String, Value>, key: &str, expected: Option<&str>) {\n assert_eq!(env.get(key).and_then(|value| value.as_str()), expected);\n }\n\n #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_copilot_models_from_provider() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"claude-haiku-4.5\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"claude-sonnet-4.6\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"claude-sonnet-4.6\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_API_KEY\": \"stale-key\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"claude-haiku-4.5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-7\"));\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n Some(\"claude-sonnet-4.6\"),\n );\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn managed_account_claude_takeover_sources_codex_models_from_provider() {\n let mut provider = Provider::with_id(\n \"codex\".to_string(),\n \"Codex\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://chatgpt.com/backend-api/codex\",\n \"ANTHROPIC_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"gpt-5.4-mini\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"gpt-5.4\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"gpt-5.4\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"codex_oauth\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://stale.example.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale-token\",\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"stale-haiku\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"Stale Haiku\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"stale-sonnet\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"stale-opus\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\": \"Stale Opus\"\n }\n });\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_env_str(env, \"ANTHROPIC_MODEL\", None);\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n Some(\"claude-haiku-4-5\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n Some(\"gpt-5.4-mini\"),\n );\n assert_env_str(\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n Some(\"claude-sonnet-4-6\"),\n );\n assert_env_str(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\", Some(\"claude-opus-4-7\"));\n assert_env_str(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\", Some(\"gpt-5.4\"));\n assert_env_str(env, \"ANTHROPIC_API_KEY\", Some(PROXY_TOKEN_PLACEHOLDER));\n assert_env_str(env, \"ANTHROPIC_AUTH_TOKEN\", None);\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-7[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"custom\"),\n \"provider-derived restore backup should retain stable Codex model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"aihubmix\").is_none());\n assert_eq!(\n backup_model_providers\n .get(\"custom\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"stable provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"custom\"),\n \"restored Codex live config should not switch history buckets\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_uses_upstream_model_without_changing_live_provider() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"custom\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "b44f83f7c5e8d08c7382d7a768ddd38a30920fa2": {"t": 1785761148.3951805, "n": 126629, "h": "7d831cbd272430d8", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-7\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n\n Self::apply_claude_takeover_fields_with_policy(config, proxy_url, auth_policy);\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = match self.read_codex_live() {\n Ok(config) => config,\n Err(_) => build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?,\n };\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_live(&effective_settings)?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n\n crate::codex_config::normalize_codex_settings_config_model_provider(\n &mut effective_settings,\n )\n .map_err(|e| format!(\"归一化 Codex restore backup 失败: {e}\"))?;\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Proxy restore writes saved live backups verbatim. Provider-driven writes go\n // through write_live_with_common_config(), which normalizes Codex provider ids.\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => {\n // Use unified helper to prepare catalog if present\n crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-7[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"custom\"),\n \"provider-derived restore backup should retain stable Codex model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"aihubmix\").is_none());\n assert_eq!(\n backup_model_providers\n .get(\"custom\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"stable provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"custom\"),\n \"restored Codex live config should not switch history buckets\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_uses_upstream_model_without_changing_live_provider() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"custom\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "791ced00345062fe3ca35bb0a4e2de0425cea04c": {"t": 1785761150.8850741, "n": 126865, "h": "f3d79fefc35b5629", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-7\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n\n Self::apply_claude_takeover_fields_with_policy(config, proxy_url, auth_policy);\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = match self.read_codex_live() {\n Ok(config) => config,\n Err(_) => build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?,\n };\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(&mut effective_settings, Some(provider));\n\n self.write_codex_live(&effective_settings)?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n Self::attach_codex_model_catalog_from_provider(\n &mut live_config,\n codex_provider.as_ref(),\n );\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n\n let anchor_config_text = existing_backup_value\n .as_ref()\n .and_then(|value| value.get(\"config\"))\n .and_then(|value| value.as_str());\n crate::codex_config::normalize_codex_settings_config_model_provider(\n &mut effective_settings,\n anchor_config_text,\n )\n .map_err(|e| format!(\"归一化 Codex restore backup 失败: {e}\"))?;\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn attach_codex_model_catalog_from_provider(\n live_config: &mut Value,\n provider: Option<&Provider>,\n ) {\n let Some(provider) = provider else {\n return;\n };\n\n let model_catalog = provider\n .settings_config\n .get(\"modelCatalog\")\n .cloned()\n .unwrap_or_else(|| json!({ \"models\": [] }));\n\n if let Some(root) = live_config.as_object_mut() {\n root.insert(\"modelCatalog\".to_string(), model_catalog);\n }\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Proxy restore writes saved live backups verbatim. Provider-driven writes go\n // through write_live_with_common_config(), which normalizes Codex provider ids.\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => {\n // Use unified helper to prepare catalog if present\n crate::codex_config::write_codex_live_with_catalog(config, auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?;\n }\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-7[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"provider-derived restore backup should retain stable Codex model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"aihubmix\").is_none());\n assert_eq!(\n backup_model_providers\n .get(\"rightcode\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"stable provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"restored Codex live config should not switch history buckets\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_uses_upstream_model_without_changing_live_provider() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"stable\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(\"deepseek-v4-flash\")\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "74acf1e3875ab54e5a4311a9d6a707a772dcdf5f": {"t": 1785761153.6516216, "n": 126088, "h": "e7bbc041fcdd1be5", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-7\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n\n Self::apply_claude_takeover_fields_with_policy(config, proxy_url, auth_policy);\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n pub async fn sync_codex_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = match self.read_codex_live() {\n Ok(config) => config,\n Err(_) => build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Codex,\n provider,\n )\n .map_err(|e| format!(\"构建 codex 有效配置失败: {e}\"))?,\n };\n let (_, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n if let Some(auth) = effective_settings\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else if let Some(root) = effective_settings.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER }),\n );\n }\n\n let config_str = effective_settings\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n Some(provider),\n );\n effective_settings[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&effective_settings)?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let codex_provider = self\n .get_current_provider_for_app(&AppType::Codex)\n .ok()\n .flatten();\n let updated_config = Self::apply_codex_proxy_toml_config_for_provider(\n config_str,\n &proxy_codex_base_url,\n codex_provider.as_ref(),\n );\n live_config[\"config\"] = json!(updated_config);\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n\n let anchor_config_text = existing_backup_value\n .as_ref()\n .and_then(|value| value.get(\"config\"))\n .and_then(|value| value.as_str());\n crate::codex_config::normalize_codex_settings_config_model_provider(\n &mut effective_settings,\n anchor_config_text,\n )\n .map_err(|e| format!(\"归一化 Codex restore backup 失败: {e}\"))?;\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n } else if live_taken_over && matches!(app_type_enum, AppType::Codex) {\n self.sync_codex_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config_for_provider(\n toml_str: &str,\n proxy_url: &str,\n provider: Option<&Provider>,\n ) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n let mut updated =\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated);\n\n if provider\n .map(crate::proxy::providers::codex_provider_uses_chat_completions)\n .unwrap_or(false)\n {\n updated = crate::codex_config::update_codex_toml_field(\n &updated,\n \"model\",\n crate::proxy::providers::CODEX_CHAT_CLIENT_MODEL,\n )\n .unwrap_or(updated);\n } else if let Some(upstream_model) =\n provider.and_then(crate::proxy::providers::codex_provider_upstream_model)\n {\n updated =\n crate::codex_config::update_codex_toml_field(&updated, \"model\", &upstream_model)\n .unwrap_or(updated);\n }\n\n updated\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{\n get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,\n };\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Proxy restore writes saved live backups verbatim. Provider-driven writes go\n // through write_live_with_common_config(), which normalizes Codex provider ids.\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?,\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output =\n ProxyService::apply_codex_proxy_toml_config_for_provider(input, proxy_url, None);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_uses_safe_client_model_for_chat_provider() {\n let input = r#\"\nmodel_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"deepseek\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n proxy_url,\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(crate::proxy::providers::CODEX_CHAT_CLIENT_MODEL)\n );\n assert_eq!(\n parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"deepseek\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_preserves_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": input\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider() {\n let input = r#\"\nmodel_provider = \"responses\"\nmodel = \"gpt-5.4\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"http://127.0.0.1:5000/v1\"\nwire_api = \"responses\"\n\"#;\n let mut provider = Provider::with_id(\n \"responses\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"config\": r#\"model_provider = \"responses\"\nmodel = \"upstream-responses-model\"\n\n[model_providers.responses]\nname = \"Responses\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n api_format: Some(\"openai_responses\".to_string()),\n ..Default::default()\n });\n\n let output = ProxyService::apply_codex_proxy_toml_config_for_provider(\n input,\n \"http://127.0.0.1:5000/v1\",\n Some(&provider),\n );\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n assert_eq!(\n parsed.get(\"model\").and_then(|v| v.as_str()),\n Some(\"upstream-responses-model\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-7[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"provider-derived restore backup should retain stable Codex model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"aihubmix\").is_none());\n assert_eq!(\n backup_model_providers\n .get(\"rightcode\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"stable provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"restored Codex live config should not switch history buckets\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_chat_provider_uses_safe_model_without_changing_live_provider() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"Responses\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"responses-key\"\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"https://responses.example/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n let mut provider_b = Provider::with_id(\n \"b\".to_string(),\n \"DeepSeek\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"deepseek-key\"\n },\n \"config\": r#\"model_provider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n\n[model_providers.deepseek]\nname = \"DeepSeek\"\nbase_url = \"https://api.deepseek.com/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }),\n None,\n );\n provider_b.meta = Some(ProviderMeta {\n api_format: Some(\"openai_chat\".to_string()),\n ..Default::default()\n });\n\n db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"stable\"\nmodel = \"responses-model\"\n\n[model_providers.stable]\nname = \"Stable\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"stable\")\n );\n assert_eq!(\n parsed_live.get(\"model\").and_then(|v| v.as_str()),\n Some(crate::proxy::providers::CODEX_CHAT_CLIENT_MODEL)\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "1c82b8a3fa7c50c56461d349aaea2de8ceaf624d": {"t": 1785761159.8493755, "n": 115528, "h": "36ee1eba0c772c93", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-7\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n\n Self::apply_claude_takeover_fields_with_policy(config, proxy_url, auth_policy);\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config =\n Self::apply_codex_proxy_toml_config(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config =\n Self::apply_codex_proxy_toml_config(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config =\n Self::apply_codex_proxy_toml_config(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n\n let anchor_config_text = existing_backup_value\n .as_ref()\n .and_then(|value| value.get(\"config\"))\n .and_then(|value| value.as_str());\n crate::codex_config::normalize_codex_settings_config_model_provider(\n &mut effective_settings,\n anchor_config_text,\n )\n .map_err(|e| format!(\"归一化 Codex restore backup 失败: {e}\"))?;\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n /// 接管 Codex 时,本地客户端必须继续以 Responses wire API 访问代理。\n /// 真实上游是否走 Chat Completions 由 provider 配置决定,并在代理内部转换。\n fn apply_codex_proxy_toml_config(toml_str: &str, proxy_url: &str) -> String {\n let updated = Self::update_toml_base_url(toml_str, proxy_url);\n crate::codex_config::update_codex_toml_field(&updated, \"wire_api\", \"responses\")\n .unwrap_or(updated)\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{\n get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,\n };\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Proxy restore writes saved live backups verbatim. Provider-driven writes go\n // through write_live_with_common_config(), which normalizes Codex provider ids.\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?,\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn apply_codex_proxy_toml_config_forces_local_responses_wire_api() {\n let input = r#\"\nmodel_provider = \"chat_only\"\nmodel = \"gpt-5.1-codex\"\n\n[model_providers.chat_only]\nname = \"Chat Only\"\nbase_url = \"https://chat-only.example/v1\"\nwire_api = \"chat\"\n\"#;\n\n let proxy_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::apply_codex_proxy_toml_config(input, proxy_url);\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let provider = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"chat_only\"))\n .expect(\"model_providers.chat_only should exist\");\n\n assert_eq!(\n provider.get(\"base_url\").and_then(|v| v.as_str()),\n Some(proxy_url)\n );\n assert_eq!(\n provider.get(\"wire_api\").and_then(|v| v.as_str()),\n Some(\"responses\")\n );\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-7[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"provider-derived restore backup should retain stable Codex model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"aihubmix\").is_none());\n assert_eq!(\n backup_model_providers\n .get(\"rightcode\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"stable provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"restored Codex live config should not switch history buckets\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "61e68d754c565a9862aaa0c693c2ddd9df528546": {"t": 1785761162.4616036, "n": 114113, "h": "d0dc45f6bafad197", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-7\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum ClaudeTakeoverAuthPolicy {\n PreserveExistingOrAuthToken,\n ManagedAccount,\n}\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n #[cfg(test)]\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n Self::apply_claude_takeover_fields_with_policy(\n config,\n proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n\n fn apply_claude_takeover_fields_for_provider(\n config: &mut Value,\n proxy_url: &str,\n provider: &Provider,\n ) {\n let auth_policy = if provider.uses_managed_account_auth() {\n ClaudeTakeoverAuthPolicy::ManagedAccount\n } else {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken\n };\n\n Self::apply_claude_takeover_fields_with_policy(config, proxy_url, auth_policy);\n }\n\n fn apply_claude_takeover_fields_with_policy(\n config: &mut Value,\n proxy_url: &str,\n auth_policy: ClaudeTakeoverAuthPolicy,\n ) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n match auth_policy {\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken => {\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n ClaudeTakeoverAuthPolicy::ManagedAccount => {\n for key in token_keys {\n env.remove(key);\n }\n env.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields_for_provider(\n &mut effective_settings,\n &proxy_url,\n provider,\n );\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n fn get_current_provider_for_app(&self, app_type: &AppType) -> Result<Option<Provider>, String> {\n let Some(current_id) = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?\n else {\n return Ok(None);\n };\n\n self.db\n .get_provider_by_id(&current_id, app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 当前供应商失败: {e}\"))\n }\n\n fn require_current_provider_for_app(&self, app_type: &AppType) -> Result<Provider, String> {\n self.get_current_provider_for_app(app_type)?\n .ok_or_else(|| format!(\"{app_type:?} 当前供应商不存在,无法接管 Live 配置\"))\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n let claude_provider = self.require_current_provider_for_app(&AppType::Claude)?;\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n &claude_provider,\n );\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n let claude_provider = self\n .get_current_provider_for_app(&AppType::Claude)\n .ok()\n .flatten();\n if let Some(provider) = claude_provider.as_ref() {\n Self::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n &proxy_url,\n provider,\n );\n } else {\n Self::apply_claude_takeover_fields_with_policy(\n &mut live_config,\n &proxy_url,\n ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken,\n );\n }\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config =\n Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n\n let anchor_config_text = existing_backup_value\n .as_ref()\n .and_then(|value| value.get(\"config\"))\n .and_then(|value| value.as_str());\n crate::codex_config::normalize_codex_settings_config_model_provider(\n &mut effective_settings,\n anchor_config_text,\n )\n .map_err(|e| format!(\"归一化 Codex restore backup 失败: {e}\"))?;\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{\n get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,\n };\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Proxy restore writes saved live backups verbatim. Provider-driven writes go\n // through write_live_with_common_config(), which normalizes Codex provider ids.\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?,\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn managed_account_claude_takeover_uses_api_key_placeholder() {\n let mut provider = Provider::with_id(\n \"copilot\".to_string(),\n \"GitHub Copilot\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.githubcopilot.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n provider_type: Some(\"github_copilot\".to_string()),\n ..Default::default()\n });\n\n let mut live_config = provider.settings_config.clone();\n ProxyService::apply_claude_takeover_fields_for_provider(\n &mut live_config,\n \"http://127.0.0.1:15721\",\n &provider,\n );\n\n let env = live_config\n .get(\"env\")\n .and_then(|value| value.as_object())\n .expect(\"env should exist\");\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\")\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").is_none(),\n \"managed OAuth providers should avoid Claude Auth Token login semantics\"\n );\n }\n\n #[test]\n fn normal_claude_takeover_without_token_keeps_auth_token_fallback() {\n let mut live_config = json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.example.com\",\n \"ANTHROPIC_MODEL\": \"claude-haiku-4.5\"\n }\n });\n\n ProxyService::apply_claude_takeover_fields(&mut live_config, \"http://127.0.0.1:15721\");\n\n assert_eq!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_AUTH_TOKEN\"))\n .and_then(|value| value.as_str()),\n Some(PROXY_TOKEN_PLACEHOLDER)\n );\n assert!(\n live_config\n .get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_API_KEY\"))\n .is_none(),\n \"non-managed providers should retain the legacy fallback behavior\"\n );\n }\n\n #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-7[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"provider-derived restore backup should retain stable Codex model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"aihubmix\").is_none());\n assert_eq!(\n backup_model_providers\n .get(\"rightcode\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"stable provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"restored Codex live config should not switch history buckets\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "b642ef0633cd85ea0a7e4ea4dd5c9210d154feda": {"t": 1785761164.4360802, "n": 108545, "h": "872280806ba73ec6", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-7\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url);\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config =\n Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n\n let anchor_config_text = existing_backup_value\n .as_ref()\n .and_then(|value| value.get(\"config\"))\n .and_then(|value| value.as_str());\n crate::codex_config::normalize_codex_settings_config_model_provider(\n &mut effective_settings,\n anchor_config_text,\n )\n .map_err(|e| format!(\"归一化 Codex restore backup 失败: {e}\"))?;\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{\n get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,\n };\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Proxy restore writes saved live backups verbatim. Provider-driven writes go\n // through write_live_with_common_config(), which normalizes Codex provider ids.\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?,\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 热更新指定应用的熔断器配置\n pub async fn update_circuit_breaker_config_for_app(\n &self,\n app_type: &str,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .update_circuit_breaker_config_for_app(app_type, config)\n .await;\n log::info!(\"已热更新 {app_type} 运行中的熔断器配置\");\n } else {\n log::debug!(\"{app_type} 熔断器配置将在下次代理启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-7[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"provider-derived restore backup should retain stable Codex model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"aihubmix\").is_none());\n assert_eq!(\n backup_model_providers\n .get(\"rightcode\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"stable provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"restored Codex live config should not switch history buckets\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "f93b935d5f41dd84ab672c0065855891e539a518": {"t": 1785761167.0356314, "n": 108006, "h": "c89c8672f42453b4", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Map, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式下 `*_MODEL` 必须由 CC Switch 写成稳定的 Claude 角色别名,\n/// 再由本地代理映射到当前供应商真实模型;`*_MODEL_NAME` 也需要同步接管,\n/// 否则 Claude Code 模型菜单会残留上一个供应商的显示名称。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 9] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\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 // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\nconst CLAUDE_TAKEOVER_HAIKU_MODEL: &str = \"claude-haiku-4-5\";\nconst CLAUDE_TAKEOVER_SONNET_MODEL: &str = \"claude-sonnet-4-6\";\nconst CLAUDE_TAKEOVER_OPUS_MODEL: &str = \"claude-opus-4-7\";\n// 写给 Claude Code 时沿用文档示例的大写形式;解析侧大小写不敏感。\nconst CLAUDE_ONE_M_MARKER_FOR_CLIENT: &str = \"[1M]\";\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n // 必须在 remove/insert 前 snapshot:避免读到自己刚写入的接管别名。\n let takeover_model_fields = Self::build_claude_takeover_model_fields(config);\n\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n for (key, value) in takeover_model_fields {\n env.insert(key.to_string(), Value::String(value));\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n\n fn build_claude_takeover_model_fields(config: &Value) -> Vec<(&'static str, String)> {\n let Some(env) = config.get(\"env\").and_then(Value::as_object) else {\n return Vec::new();\n };\n\n let default_model = Self::claude_env_string(env, \"ANTHROPIC_MODEL\");\n let small_fast_model = Self::claude_env_string(env, \"ANTHROPIC_SMALL_FAST_MODEL\");\n let haiku_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .or(small_fast_model)\n .or(default_model);\n let sonnet_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n let opus_model = Self::claude_env_string(env, \"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .or(default_model)\n .or(small_fast_model);\n\n let mut fields = Vec::with_capacity(6);\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\",\n CLAUDE_TAKEOVER_HAIKU_MODEL,\n false,\n haiku_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\",\n CLAUDE_TAKEOVER_SONNET_MODEL,\n true,\n sonnet_model,\n );\n Self::push_claude_takeover_role_fields(\n &mut fields,\n env,\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\",\n CLAUDE_TAKEOVER_OPUS_MODEL,\n true,\n opus_model,\n );\n fields\n }\n\n fn push_claude_takeover_role_fields(\n fields: &mut Vec<(&'static str, String)>,\n env: &Map<String, Value>,\n model_key: &'static str,\n name_key: &'static str,\n takeover_model: &'static str,\n supports_one_m: bool,\n upstream_model: Option<&str>,\n ) {\n let Some(upstream_model) = upstream_model else {\n return;\n };\n\n let mut client_model = takeover_model.to_string();\n if supports_one_m && Self::has_claude_one_m_marker(upstream_model) {\n client_model.push_str(CLAUDE_ONE_M_MARKER_FOR_CLIENT);\n }\n fields.push((model_key, client_model));\n\n let display_name = Self::claude_env_string(env, name_key)\n .map(str::to_string)\n .unwrap_or_else(|| Self::strip_claude_one_m_marker(upstream_model));\n if !display_name.is_empty() {\n fields.push((name_key, display_name));\n }\n }\n\n fn claude_env_string<'a>(env: &'a Map<String, Value>, key: &str) -> Option<&'a str> {\n env.get(key)\n .and_then(Value::as_str)\n .map(str::trim)\n .filter(|value| !value.is_empty())\n }\n\n fn has_claude_one_m_marker(model: &str) -> bool {\n model\n .trim_end()\n .to_ascii_lowercase()\n .ends_with(crate::claude_desktop_config::ONE_M_CONTEXT_MARKER)\n }\n\n fn strip_claude_one_m_marker(model: &str) -> String {\n crate::proxy::model_mapper::strip_one_m_suffix_for_upstream(model)\n .trim()\n .to_string()\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url);\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config =\n Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n\n let anchor_config_text = existing_backup_value\n .as_ref()\n .and_then(|value| value.get(\"config\"))\n .and_then(|value| value.as_str());\n crate::codex_config::normalize_codex_settings_config_model_provider(\n &mut effective_settings,\n anchor_config_text,\n )\n .map_err(|e| format!(\"归一化 Codex restore backup 失败: {e}\"))?;\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{\n get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,\n };\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Proxy restore writes saved live backups verbatim. Provider-driven writes go\n // through write_live_with_common_config(), which normalizes Codex provider ids.\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?,\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": \"deepseek-v4-flash\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\": \"DeepSeek V4 Flash\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\": \"deepseek-v4-pro[1M]\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"DeepSeek V4 Pro\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\": \"deepseek-v4-ultra [1m]\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\": \"Stale Sonnet\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"fallback model override should be removed in takeover mode\"\n );\n let live_env = live\n .get(\"env\")\n .and_then(|env| env.as_object())\n .expect(\"live env\");\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-haiku-4-5\"),\n \"takeover mode should expose a stable Haiku role model\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Flash\"),\n \"model menu should show the current provider Haiku display name\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-sonnet-4-6[1M]\"),\n \"Sonnet role should carry the local 1M declaration for Claude Code\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"DeepSeek V4 Pro\"),\n \"stale model display names should be replaced during hot switch\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL\")\n .and_then(|v| v.as_str()),\n Some(\"claude-opus-4-7[1M]\"),\n \"Opus role should preserve the current provider 1M capability marker\"\n );\n assert_eq!(\n live_env\n .get(\"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME\")\n .and_then(|v| v.as_str()),\n Some(\"deepseek-v4-ultra\"),\n \"implicit display names should strip the local 1M marker\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"provider-derived restore backup should retain stable Codex model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"aihubmix\").is_none());\n assert_eq!(\n backup_model_providers\n .get(\"rightcode\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"stable provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"restored Codex live config should not switch history buckets\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "c3d810a22bec2602f8c13bf3587bdf5d27100af4": {"t": 1785761168.5956326, "n": 102609, "h": "c9e0c34ddcb01b74", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,\n/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n /// 清理接管模式下 Claude Live 配置中的模型覆盖字段。\n ///\n /// 这可以避免\"接管开启后切换供应商仍使用旧模型\"的问题。\n /// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。\n pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n let mut changed = false;\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n if env.remove(key).is_some() {\n changed = true;\n }\n }\n\n if changed {\n self.write_claude_live(&config)?;\n }\n\n Ok(())\n }\n\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url);\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n // 必须 backup AND live 占位符同时存在才算真接管。\n // 只看其一会出现「UI 显示已接管但 Live 已被恢复」或「Live 仍是占位符但备份丢失」\n // 两种脏角落,下面的重建分支会把这些情况修复成一致状态。\n if has_backup && live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但 backup={has_backup} live_taken_over={live_taken_over},正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n //\n // 必须走 with_fallback 版本:备份 → SSOT → 清理占位符 的三层兜底。\n // 简版 restore_live_config_for_app 在备份缺失时会静默 Ok(()),\n // 留下接管时写入的占位符(代理地址/PROXY_MANAGED token),客户端无法工作。\n self.restore_live_config_for_app_with_fallback(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config =\n Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n\n let anchor_config_text = existing_backup_value\n .as_ref()\n .and_then(|value| value.get(\"config\"))\n .and_then(|value| value.as_str());\n crate::codex_config::normalize_codex_settings_config_model_provider(\n &mut effective_settings,\n anchor_config_text,\n )\n .map_err(|e| format!(\"归一化 Codex restore backup 失败: {e}\"))?;\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n if let Err(e) = self.cleanup_claude_model_overrides_in_live() {\n log::warn!(\"清理 Claude Live 模型字段失败(不影响热切换结果): {e}\");\n }\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{\n get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,\n };\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Proxy restore writes saved live backups verbatim. Provider-driven writes go\n // through write_live_with_common_config(), which normalizes Codex provider ids.\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?,\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"Claude model override fields should be removed in takeover mode\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"provider-derived restore backup should retain stable Codex model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"aihubmix\").is_none());\n assert_eq!(\n backup_model_providers\n .get(\"rightcode\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"stable provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"restored Codex live config should not switch history buckets\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "83f4e1d0add6c023b5dba1e0afc31d17bd75896d": {"t": 1785761170.2491677, "n": 102198, "h": "4eea47d5f4e0ed5e", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,\n/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n /// 清理接管模式下 Claude Live 配置中的模型覆盖字段。\n ///\n /// 这可以避免\"接管开启后切换供应商仍使用旧模型\"的问题。\n /// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。\n pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n let mut changed = false;\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n if env.remove(key).is_some() {\n changed = true;\n }\n }\n\n if changed {\n self.write_claude_live(&config)?;\n }\n\n Ok(())\n }\n\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url);\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n if has_backup || live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但缺少备份或占位符,正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n self.restore_live_config_for_app(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n _ => return Err(\"该应用不支持代理功能\".to_string()),\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config =\n Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n _ => {}\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n _ => Err(\"该应用不支持代理功能\".to_string()),\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n _ => false,\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n _ => Ok(()),\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n\n let anchor_config_text = existing_backup_value\n .as_ref()\n .and_then(|value| value.get(\"config\"))\n .and_then(|value| value.as_str());\n crate::codex_config::normalize_codex_settings_config_model_provider(\n &mut effective_settings,\n anchor_config_text,\n )\n .map_err(|e| format!(\"归一化 Codex restore backup 失败: {e}\"))?;\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n _ => return Err(format!(\"未知的应用类型: {app_type}\")),\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n if let Err(e) = self.cleanup_claude_model_overrides_in_live() {\n log::warn!(\"清理 Claude Live 模型字段失败(不影响热切换结果): {e}\");\n }\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{\n get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,\n };\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Proxy restore writes saved live backups verbatim. Provider-driven writes go\n // through write_live_with_common_config(), which normalizes Codex provider ids.\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?,\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"Claude model override fields should be removed in takeover mode\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"provider-derived restore backup should retain stable Codex model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"aihubmix\").is_none());\n assert_eq!(\n backup_model_providers\n .get(\"rightcode\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"stable provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"restored Codex live config should not switch history buckets\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "8b3ad9caf9cb1a5ac9e4fd098e56e15a54fabcc0": {"t": 1785761172.6057546, "n": 103824, "h": "07b46926caf5b90e", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,\n/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n /// 清理接管模式下 Claude Live 配置中的模型覆盖字段。\n ///\n /// 这可以避免\"接管开启后切换供应商仍使用旧模型\"的问题。\n /// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。\n pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n let mut changed = false;\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n if env.remove(key).is_some() {\n changed = true;\n }\n }\n\n if changed {\n self.write_claude_live(&config)?;\n }\n\n Ok(())\n }\n\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url);\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n if has_backup || live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但缺少备份或占位符,正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n self.restore_live_config_for_app(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {\n // These apps don't support proxy features\n return Err(\"该应用不支持代理功能\".to_string());\n }\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {\n // These apps don't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {\n // These apps don't support proxy features\n return Err(\"该应用不支持代理功能\".to_string());\n }\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {\n // These apps don't support proxy features\n return Err(\"该应用不支持代理功能\".to_string());\n }\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config =\n Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {\n // These apps don't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {\n // These apps don't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {\n // These apps don't support proxy features\n Err(\"该应用不支持代理功能\".to_string())\n }\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {\n // These apps don't support proxy takeover\n false\n }\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {\n // These apps don't support proxy features\n Ok(())\n }\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n\n let anchor_config_text = existing_backup_value\n .as_ref()\n .and_then(|value| value.get(\"config\"))\n .and_then(|value| value.as_str());\n crate::codex_config::normalize_codex_settings_config_model_provider(\n &mut effective_settings,\n anchor_config_text,\n )\n .map_err(|e| format!(\"归一化 Codex restore backup 失败: {e}\"))?;\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes | AppType::ClaudeDesktop => {\n return Err(format!(\"未知的应用类型: {app_type}\"));\n }\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n if let Err(e) = self.cleanup_claude_model_overrides_in_live() {\n log::warn!(\"清理 Claude Live 模型字段失败(不影响热切换结果): {e}\");\n }\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{\n get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,\n };\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Proxy restore writes saved live backups verbatim. Provider-driven writes go\n // through write_live_with_common_config(), which normalizes Codex provider ids.\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?,\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"Claude model override fields should be removed in takeover mode\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"provider-derived restore backup should retain stable Codex model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"aihubmix\").is_none());\n assert_eq!(\n backup_model_providers\n .get(\"rightcode\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"stable provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"restored Codex live config should not switch history buckets\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "a1e6c3b65de4ccc2a91f54c5ca8564f331ccaf44": {"t": 1785761174.6367688, "n": 103574, "h": "0d8c254f4d6389c8", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,\n/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n /// 清理接管模式下 Claude Live 配置中的模型覆盖字段。\n ///\n /// 这可以避免\"接管开启后切换供应商仍使用旧模型\"的问题。\n /// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。\n pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n let mut changed = false;\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n if env.remove(key).is_some() {\n changed = true;\n }\n }\n\n if changed {\n self.write_claude_live(&config)?;\n }\n\n Ok(())\n }\n\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url);\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n if has_backup || live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但缺少备份或占位符,正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n self.restore_live_config_for_app(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features\n return Err(\"该应用不支持代理功能\".to_string());\n }\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features\n return Err(\"该应用不支持代理功能\".to_string());\n }\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features\n return Err(\"该应用不支持代理功能\".to_string());\n }\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config =\n Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features\n Err(\"该应用不支持代理功能\".to_string())\n }\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy takeover\n false\n }\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features\n Ok(())\n }\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup_value = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?\n .map(|backup| {\n serde_json::from_str::<Value>(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))\n })\n .transpose()?;\n\n if let Some(existing_value) = existing_backup_value.as_ref() {\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n existing_value,\n )?;\n }\n\n let anchor_config_text = existing_backup_value\n .as_ref()\n .and_then(|value| value.get(\"config\"))\n .and_then(|value| value.as_str());\n crate::codex_config::normalize_codex_settings_config_model_provider(\n &mut effective_settings,\n anchor_config_text,\n )\n .map_err(|e| format!(\"归一化 Codex restore backup 失败: {e}\"))?;\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n return Err(format!(\"未知的应用类型: {app_type}\"));\n }\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n if let Err(e) = self.cleanup_claude_model_overrides_in_live() {\n log::warn!(\"清理 Claude Live 模型字段失败(不影响热切换结果): {e}\");\n }\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{\n get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,\n };\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n // Proxy restore writes saved live backups verbatim. Provider-driven writes go\n // through write_live_with_common_config(), which normalizes Codex provider ids.\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?,\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"Claude model override fields should be removed in takeover mode\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_codex_provider_keeps_model_provider_stable_in_backup_and_restore() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"RightCode\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"rightcode-key\"\n },\n \"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 }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"AiHubMix\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"aihubmix-key\"\n },\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 db.save_provider(\"codex\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"codex\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"codex\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Codex, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_codex_live(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n },\n \"config\": r#\"model_provider = \"rightcode\"\nmodel = \"gpt-5.4\"\n\n[model_providers.rightcode]\nname = \"RightCode\"\nbase_url = \"http://127.0.0.1:15721/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#\n }))\n .expect(\"seed taken-over Codex live config\");\n\n service\n .hot_switch_provider(\"codex\", \"b\")\n .await\n .expect(\"hot switch Codex provider\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let backup_config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"backup config string\");\n let parsed_backup: toml::Value =\n toml::from_str(backup_config).expect(\"parse backup config\");\n assert_eq!(\n parsed_backup.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"provider-derived restore backup should retain stable Codex model_provider\"\n );\n let backup_model_providers = parsed_backup\n .get(\"model_providers\")\n .and_then(|v| v.as_table())\n .expect(\"backup model_providers\");\n assert!(backup_model_providers.get(\"aihubmix\").is_none());\n assert_eq!(\n backup_model_providers\n .get(\"rightcode\")\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str()),\n Some(\"https://aihubmix.example/v1\"),\n \"stable provider id should point at the hot-switched provider endpoint\"\n );\n\n service\n .restore_live_config_for_app_with_fallback(&AppType::Codex)\n .await\n .expect(\"restore Codex live config\");\n\n let live = service.read_codex_live().expect(\"read Codex live config\");\n let live_config = live\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"live config string\");\n let parsed_live: toml::Value = toml::from_str(live_config).expect(\"parse live config\");\n assert_eq!(\n parsed_live.get(\"model_provider\").and_then(|v| v.as_str()),\n Some(\"rightcode\"),\n \"restored Codex live config should not switch history buckets\"\n );\n assert_eq!(\n live.get(\"auth\")\n .and_then(|auth| auth.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str()),\n Some(\"aihubmix-key\"),\n \"restore should still use the hot-switched provider auth\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "81af0a57f99af854c22094419412f05f8e2f212e": {"t": 1785761176.1275458, "n": 98084, "h": "ea02793a57f85dc6", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,\n/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n /// 清理接管模式下 Claude Live 配置中的模型覆盖字段。\n ///\n /// 这可以避免\"接管开启后切换供应商仍使用旧模型\"的问题。\n /// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。\n pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n let mut changed = false;\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n if env.remove(key).is_some() {\n changed = true;\n }\n }\n\n if changed {\n self.write_claude_live(&config)?;\n }\n\n Ok(())\n }\n\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url);\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n if has_backup || live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但缺少备份或占位符,正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n self.restore_live_config_for_app(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features\n return Err(\"该应用不支持代理功能\".to_string());\n }\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features\n return Err(\"该应用不支持代理功能\".to_string());\n }\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features\n return Err(\"该应用不支持代理功能\".to_string());\n }\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config =\n Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features\n Err(\"该应用不支持代理功能\".to_string())\n }\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy takeover\n false\n }\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n // These apps don't support proxy features\n Ok(())\n }\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?;\n\n if let Some(existing_backup) = existing_backup {\n let existing_value: Value = serde_json::from_str(&existing_backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))?;\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n &existing_value,\n )?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {\n return Err(format!(\"未知的应用类型: {app_type}\"));\n }\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n if let Err(e) = self.cleanup_claude_model_overrides_in_live() {\n log::warn!(\"清理 Claude Live 模型字段失败(不影响热切换结果): {e}\");\n }\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{\n get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,\n };\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?,\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"Claude model override fields should be removed in takeover mode\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "e4c34b34e75f727278a24c87f650b670f1fdb9e2": {"t": 1785761177.8141491, "n": 99042, "h": "2475fd6c32801117", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,\n/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\", // legacy: 已废弃,但旧配置可能残留\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n /// 清理接管模式下 Claude Live 配置中的模型覆盖字段。\n ///\n /// 这可以避免\"接管开启后切换供应商仍使用旧模型\"的问题。\n /// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。\n pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n let mut changed = false;\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n if env.remove(key).is_some() {\n changed = true;\n }\n }\n\n if changed {\n self.write_claude_live(&config)?;\n }\n\n Ok(())\n }\n\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url);\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n if has_backup || live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但缺少备份或占位符,正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n self.restore_live_config_for_app(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n return Err(\"OpenCode 不支持代理功能\".to_string());\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n return Err(\"OpenClaw 不支持代理功能\".to_string());\n }\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features, skip silently\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n return Err(\"OpenCode 不支持代理功能\".to_string());\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n return Err(\"OpenClaw 不支持代理功能\".to_string());\n }\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n return Err(\"OpenCode 不支持代理功能\".to_string());\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n return Err(\"OpenClaw 不支持代理功能\".to_string());\n }\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config =\n Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features, skip silently\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features, skip silently\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n Err(\"OpenCode 不支持代理功能\".to_string())\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n Err(\"OpenClaw 不支持代理功能\".to_string())\n }\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::OpenCode => {\n // OpenCode doesn't support proxy takeover\n false\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy takeover\n false\n }\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n Ok(())\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n Ok(())\n }\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?;\n\n if let Some(existing_backup) = existing_backup {\n let existing_value: Value = serde_json::from_str(&existing_backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))?;\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n &existing_value,\n )?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n AppType::OpenCode | AppType::OpenClaw => {\n return Err(format!(\"未知的应用类型: {app_type}\"));\n }\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n if let Err(e) = self.cleanup_claude_model_overrides_in_live() {\n log::warn!(\"清理 Claude Live 模型字段失败(不影响热切换结果): {e}\");\n }\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{\n get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,\n };\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?,\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"Claude model override fields should be removed in takeover mode\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "a9f0ba3b4b2752ca2665d610edae82d679dccaf6": {"t": 1785761179.4857366, "n": 99018, "h": "7aa073dadce7a805", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,\n/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n /// 清理接管模式下 Claude Live 配置中的模型覆盖字段。\n ///\n /// 这可以避免\"接管开启后切换供应商仍使用旧模型\"的问题。\n /// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。\n pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n let mut changed = false;\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n if env.remove(key).is_some() {\n changed = true;\n }\n }\n\n if changed {\n self.write_claude_live(&config)?;\n }\n\n Ok(())\n }\n\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url);\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n if has_backup || live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但缺少备份或占位符,正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n self.restore_live_config_for_app(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n return Err(\"OpenCode 不支持代理功能\".to_string());\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n return Err(\"OpenClaw 不支持代理功能\".to_string());\n }\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features, skip silently\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n return Err(\"OpenCode 不支持代理功能\".to_string());\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n return Err(\"OpenClaw 不支持代理功能\".to_string());\n }\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n return Err(\"OpenCode 不支持代理功能\".to_string());\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n return Err(\"OpenClaw 不支持代理功能\".to_string());\n }\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config =\n Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features, skip silently\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features, skip silently\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n Err(\"OpenCode 不支持代理功能\".to_string())\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n Err(\"OpenClaw 不支持代理功能\".to_string())\n }\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::OpenCode => {\n // OpenCode doesn't support proxy takeover\n false\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy takeover\n false\n }\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n Ok(())\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n Ok(())\n }\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?;\n\n if let Some(existing_backup) = existing_backup {\n let existing_value: Value = serde_json::from_str(&existing_backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))?;\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n &existing_value,\n )?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n AppType::OpenCode | AppType::OpenClaw => {\n return Err(format!(\"未知的应用类型: {app_type}\"));\n }\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n if let Err(e) = self.cleanup_claude_model_overrides_in_live() {\n log::warn!(\"清理 Claude Live 模型字段失败(不影响热切换结果): {e}\");\n }\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{\n get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,\n };\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?,\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"Claude model override fields should be removed in takeover mode\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "a514f279372ca83636d873dd31ea886837e11fef": {"t": 1785761181.098788, "n": 99018, "h": "7aa073dadce7a805", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tauri::Emitter;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,\n/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n /// 清理接管模式下 Claude Live 配置中的模型覆盖字段。\n ///\n /// 这可以避免\"接管开启后切换供应商仍使用旧模型\"的问题。\n /// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。\n pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n let mut changed = false;\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n if env.remove(key).is_some() {\n changed = true;\n }\n }\n\n if changed {\n self.write_claude_live(&config)?;\n }\n\n Ok(())\n }\n\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url);\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n if has_backup || live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但缺少备份或占位符,正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n\n // 8) Warn if the current provider is official (risk of account ban via proxy)\n if let Ok(Some(current_id)) =\n crate::settings::get_effective_current_provider(&self.db, &app)\n {\n if let Ok(Some(provider)) = self.db.get_provider_by_id(&current_id, app_type_str) {\n if provider.category.as_deref() == Some(\"official\") {\n if let Some(handle) = self.app_handle.read().await.as_ref() {\n let _ = handle.emit(\n \"proxy-official-warning\",\n serde_json::json!({\n \"appType\": app_type_str,\n \"providerName\": provider.name,\n }),\n );\n }\n }\n }\n }\n\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n self.restore_live_config_for_app(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n return Err(\"OpenCode 不支持代理功能\".to_string());\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n return Err(\"OpenClaw 不支持代理功能\".to_string());\n }\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features, skip silently\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n return Err(\"OpenCode 不支持代理功能\".to_string());\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n return Err(\"OpenClaw 不支持代理功能\".to_string());\n }\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n return Err(\"OpenCode 不支持代理功能\".to_string());\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n return Err(\"OpenClaw 不支持代理功能\".to_string());\n }\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config =\n Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features, skip silently\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features, skip silently\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n Err(\"OpenCode 不支持代理功能\".to_string())\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n Err(\"OpenClaw 不支持代理功能\".to_string())\n }\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::OpenCode => {\n // OpenCode doesn't support proxy takeover\n false\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy takeover\n false\n }\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n Ok(())\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n Ok(())\n }\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?;\n\n if let Some(existing_backup) = existing_backup {\n let existing_value: Value = serde_json::from_str(&existing_backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))?;\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n &existing_value,\n )?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n AppType::OpenCode | AppType::OpenClaw => {\n return Err(format!(\"未知的应用类型: {app_type}\"));\n }\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n // Defense-in-depth: block official providers during proxy takeover\n if provider.category.as_deref() == Some(\"official\") {\n return Err(\n \"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)\"\n .to_string(),\n );\n }\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n if let Err(e) = self.cleanup_claude_model_overrides_in_live() {\n log::warn!(\"清理 Claude Live 模型字段失败(不影响热切换结果): {e}\");\n }\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{\n get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,\n };\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?,\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"Claude model override fields should be removed in takeover mode\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}, "8b44d9f54d9788e7596dd316b8cb4271fa5d65fe": {"t": 1785761182.9070766, "n": 97758, "h": "4dfb4f2d762f6a93", "c": "//! 代理服务业务逻辑层\n//!\n//! 提供代理服务器的启动、停止和配置管理\n\nuse crate::app_config::AppType;\nuse crate::config::{get_claude_settings_path, read_json_file, write_json_file};\nuse crate::database::Database;\nuse crate::provider::Provider;\nuse crate::proxy::server::ProxyServer;\nuse crate::proxy::switch_lock::SwitchLockManager;\nuse crate::proxy::types::*;\nuse crate::services::provider::{\n build_effective_settings_with_common_config, write_live_with_common_config,\n};\nuse serde_json::{json, Value};\nuse std::str::FromStr;\nuse std::sync::Arc;\nuse tokio::sync::RwLock;\n\n/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)\nconst PROXY_TOKEN_PLACEHOLDER: &str = \"PROXY_MANAGED\";\n\n/// 代理接管模式下需要从 Claude Live 配置中移除的\"模型覆盖\"字段。\n///\n/// 原因:接管模式切换供应商时不会写回 Live 配置,如果保留这些字段,\n/// Claude Code 会继续以旧模型名发起请求,导致新供应商不支持时失败。\nconst CLAUDE_MODEL_OVERRIDE_ENV_KEYS: [&str; 6] = [\n \"ANTHROPIC_MODEL\",\n \"ANTHROPIC_REASONING_MODEL\",\n \"ANTHROPIC_DEFAULT_HAIKU_MODEL\",\n \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n // Legacy key (已废弃):历史版本使用该字段区分 small/fast 模型\n \"ANTHROPIC_SMALL_FAST_MODEL\",\n];\n\n#[derive(Clone)]\npub struct ProxyService {\n db: Arc<Database>,\n server: Arc<RwLock<Option<ProxyServer>>>,\n /// AppHandle,用于传递给 ProxyServer 以支持故障转移时的 UI 更新\n app_handle: Arc<RwLock<Option<tauri::AppHandle>>>,\n switch_locks: SwitchLockManager,\n}\n\n#[derive(Debug, Clone, Copy, Default)]\npub struct HotSwitchOutcome {\n pub logical_target_changed: bool,\n}\n\nimpl ProxyService {\n pub fn new(db: Arc<Database>) -> Self {\n Self {\n db,\n server: Arc::new(RwLock::new(None)),\n app_handle: Arc::new(RwLock::new(None)),\n switch_locks: SwitchLockManager::new(),\n }\n }\n\n /// 清理接管模式下 Claude Live 配置中的模型覆盖字段。\n ///\n /// 这可以避免\"接管开启后切换供应商仍使用旧模型\"的问题。\n /// 注意:此方法不会修改 Token/Base URL 的接管占位符,仅移除模型字段。\n pub fn cleanup_claude_model_overrides_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n let mut changed = false;\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n if env.remove(key).is_some() {\n changed = true;\n }\n }\n\n if changed {\n self.write_claude_live(&config)?;\n }\n\n Ok(())\n }\n\n fn apply_claude_takeover_fields(config: &mut Value, proxy_url: &str) {\n if !config.is_object() {\n *config = json!({});\n }\n\n let root = config\n .as_object_mut()\n .expect(\"Claude config should be normalized to an object\");\n let env = root.entry(\"env\".to_string()).or_insert_with(|| json!({}));\n if !env.is_object() {\n *env = json!({});\n }\n\n let env = env\n .as_object_mut()\n .expect(\"Claude env should be normalized to an object\");\n env.insert(\"ANTHROPIC_BASE_URL\".to_string(), json!(proxy_url));\n\n for key in CLAUDE_MODEL_OVERRIDE_ENV_KEYS {\n env.remove(key);\n }\n\n let token_keys = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ];\n\n let mut replaced_any = false;\n for key in token_keys {\n if env.contains_key(key) {\n env.insert(key.to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n replaced_any = true;\n }\n }\n\n if !replaced_any {\n env.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(PROXY_TOKEN_PLACEHOLDER),\n );\n }\n }\n\n pub async fn sync_claude_live_from_provider_while_proxy_active(\n &self,\n provider: &Provider,\n ) -> Result<(), String> {\n let mut effective_settings = build_effective_settings_with_common_config(\n self.db.as_ref(),\n &AppType::Claude,\n provider,\n )\n .map_err(|e| format!(\"构建 claude 有效配置失败: {e}\"))?;\n let (proxy_url, _) = self.build_proxy_urls().await?;\n\n Self::apply_claude_takeover_fields(&mut effective_settings, &proxy_url);\n self.write_claude_live(&effective_settings)?;\n Ok(())\n }\n\n /// 设置 AppHandle(在应用初始化时调用)\n pub fn set_app_handle(&self, handle: tauri::AppHandle) {\n futures::executor::block_on(async {\n *self.app_handle.write().await = Some(handle);\n });\n }\n\n /// 启动代理服务器\n pub async fn start(&self) -> Result<ProxyServerInfo, String> {\n // 1. 启动时自动设置 proxy_enabled = true\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if !global_config.proxy_enabled {\n global_config.proxy_enabled = true;\n self.db\n .update_global_proxy_config(global_config.clone())\n .await\n .map_err(|e| format!(\"更新代理总开关失败: {e}\"))?;\n }\n\n // 2. 获取配置\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 3. 若已在运行:确保持久化状态(如需要)并返回当前信息\n if let Some(server) = self.server.read().await.as_ref() {\n let status = server.get_status().await;\n return Ok(ProxyServerInfo {\n address: status.address,\n port: status.port,\n // 无法精确取回首次启动时间,返回当前时间用于 UI 展示即可\n started_at: chrono::Utc::now().to_rfc3339(),\n });\n }\n\n // 4. 创建并启动服务器\n let app_handle = self.app_handle.read().await.clone();\n let server = ProxyServer::new(config.clone(), self.db.clone(), app_handle);\n let info = server\n .start()\n .await\n .map_err(|e| format!(\"启动代理服务器失败: {e}\"))?;\n\n // 5. 保存服务器实例\n *self.server.write().await = Some(server);\n\n log::info!(\"代理服务器已启动: {}:{}\", info.address, info.port);\n Ok(info)\n }\n\n /// 启动代理服务器(带 Live 配置接管)\n pub async fn start_with_takeover(&self) -> Result<ProxyServerInfo, String> {\n // 1. 备份各应用的 Live 配置\n self.backup_live_configs().await?;\n\n // 2. 同步 Live 配置中的 Token 到数据库(确保代理能读到最新的 Token)\n if let Err(e) = self.sync_live_to_providers().await {\n // 同步失败时尚未写入接管配置,但备份可能包含敏感信息,尽量清理\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(e);\n }\n\n // 3. 在写入接管配置之前先落盘接管标志:\n // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。\n if let Err(e) = self.db.set_live_takeover_active(true).await {\n if let Err(clean_err) = self.db.delete_all_live_backups().await {\n log::warn!(\"清理 Live 备份失败: {clean_err}\");\n }\n return Err(format!(\"设置接管状态失败: {e}\"));\n }\n\n // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token)\n if let Err(e) = self.takeover_live_configs().await {\n // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。\n log::error!(\"接管 Live 配置失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n return Err(e);\n }\n\n // 5. 启动代理服务器\n match self.start().await {\n Ok(info) => Ok(info),\n Err(e) => {\n // 启动失败,恢复原始配置\n log::error!(\"代理启动失败,尝试恢复原始配置: {e}\");\n match self.restore_live_configs().await {\n Ok(()) => {\n let _ = self.db.set_live_takeover_active(false).await;\n let _ = self.db.delete_all_live_backups().await;\n }\n Err(restore_err) => {\n log::error!(\"恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}\");\n }\n }\n Err(e)\n }\n }\n }\n\n /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)\n pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {\n // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测\n let claude_enabled = self\n .db\n .get_proxy_config_for_app(\"claude\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let codex_enabled = self\n .db\n .get_proxy_config_for_app(\"codex\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n let gemini_enabled = self\n .db\n .get_proxy_config_for_app(\"gemini\")\n .await\n .map(|c| c.enabled)\n .unwrap_or(false);\n // OpenCode and OpenClaw don't support proxy features, always return false\n let opencode_enabled = false;\n let openclaw_enabled = false;\n\n Ok(ProxyTakeoverStatus {\n claude: claude_enabled,\n codex: codex_enabled,\n gemini: gemini_enabled,\n opencode: opencode_enabled,\n openclaw: openclaw_enabled,\n })\n }\n\n /// 为指定应用开启/关闭 Live 接管\n ///\n /// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置\n /// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务\n pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {\n let app = AppType::from_str(app_type).map_err(|e| format!(\"无效的应用类型: {e}\"))?;\n let app_type_str = app.as_str();\n\n if enabled {\n // 1) 代理服务未运行则自动启动\n if !self.is_running().await {\n self.start().await?;\n }\n\n // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if current_config.enabled {\n let has_backup = match self.db.get_live_backup(app_type_str).await {\n Ok(v) => v.is_some(),\n Err(e) => {\n log::warn!(\"读取 {app_type_str} 备份失败(将继续重建接管): {e}\");\n false\n }\n };\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);\n\n if has_backup || live_taken_over {\n return Ok(());\n }\n\n log::warn!(\n \"{app_type_str} 标记为已接管,但缺少备份或占位符,正在重新接管并补齐备份\"\n );\n }\n\n // 3) 备份 Live 配置(严格:目标 app 不存在则报错)\n self.backup_live_config_strict(&app).await?;\n\n // 4) 同步 Live Token 到数据库(仅当前 app)\n if let Err(e) = self.sync_live_to_provider(&app).await {\n let _ = self.db.delete_live_backup(app_type_str).await;\n return Err(e);\n }\n\n // 5) 写入接管配置(仅当前 app)\n if let Err(e) = self.takeover_live_config_strict(&app).await {\n log::error!(\"{app_type_str} 接管 Live 配置失败,尝试恢复: {e}\");\n match self.restore_live_config_for_app(&app).await {\n Ok(()) => {\n // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源\n let _ = self.db.delete_live_backup(app_type_str).await;\n }\n Err(restore_err) => {\n log::error!(\n \"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}\"\n );\n }\n }\n return Err(e);\n }\n\n // 6) 设置 proxy_config.enabled = true\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = true;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"设置 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)\n let _ = self.db.set_live_takeover_active(true).await;\n return Ok(());\n }\n\n // 关闭接管:检查 enabled 状态\n let current_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n\n if !current_config.enabled {\n return Ok(()); // 未接管,幂等返回\n }\n\n // 1) 恢复 Live 配置\n self.restore_live_config_for_app(&app).await?;\n\n // 2) 删除该 app 的备份(避免长期存储敏感 Token)\n self.db\n .delete_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"删除 {app_type_str} Live 备份失败: {e}\"))?;\n\n // 3) 设置 proxy_config.enabled = false\n let mut updated_config = self\n .db\n .get_proxy_config_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} 配置失败: {e}\"))?;\n updated_config.enabled = false;\n self.db\n .update_proxy_config_for_app(updated_config)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} enabled 状态失败: {e}\"))?;\n\n // 4) 清除该应用的健康状态(关闭代理时重置队列状态)\n self.db\n .clear_provider_health_for_app(app_type_str)\n .await\n .map_err(|e| format!(\"清除 {app_type_str} 健康状态失败: {e}\"))?;\n\n // 5) 若无其它接管,更新旧标志,并停止代理服务\n // 检查是否还有其它 app 的 enabled = true\n let any_enabled = self\n .db\n .is_live_takeover_active()\n .await\n .map_err(|e| format!(\"检查接管状态失败: {e}\"))?;\n\n if !any_enabled {\n let _ = self.db.set_live_takeover_active(false).await;\n\n if self.is_running().await {\n // 此时没有任何 app 处于接管状态,停止服务即可\n let _ = self.stop().await;\n }\n }\n\n Ok(())\n }\n\n /// 同步 Live 配置中的 Token 到数据库\n ///\n /// 在清空 Live Token 之前调用,确保数据库中的 Provider 配置有最新的 Token。\n /// 这样代理才能从数据库读取到正确的认证信息。\n async fn sync_live_to_provider(&self, app_type: &AppType) -> Result<(), String> {\n let live_config = match app_type {\n AppType::Claude => self.read_claude_live()?,\n AppType::Codex => self.read_codex_live()?,\n AppType::Gemini => self.read_gemini_live()?,\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n return Err(\"OpenCode 不支持代理功能\".to_string());\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n return Err(\"OpenClaw 不支持代理功能\".to_string());\n }\n };\n\n self.sync_live_config_to_provider(app_type, &live_config)\n .await\n }\n\n async fn sync_live_config_to_provider(\n &self,\n app_type: &AppType,\n live_config: &Value,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Claude)\n .map_err(|e| format!(\"获取 Claude 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"claude\")\n {\n if let Some(env) = live_config.get(\"env\").and_then(|v| v.as_object()) {\n let token_pair = [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ]\n .into_iter()\n .find_map(|key| {\n env.get(key)\n .and_then(|v| v.as_str())\n .map(|s| (key, s.trim()))\n })\n .filter(|(_, token)| {\n !token.is_empty() && *token != PROXY_TOKEN_PLACEHOLDER\n });\n\n if let Some((token_key, token)) = token_pair {\n let env_obj = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut());\n\n match env_obj {\n Some(obj) => {\n if token_key == \"ANTHROPIC_AUTH_TOKEN\"\n || token_key == \"ANTHROPIC_API_KEY\"\n {\n let mut updated = false;\n if obj.contains_key(\"ANTHROPIC_AUTH_TOKEN\") {\n obj.insert(\n \"ANTHROPIC_AUTH_TOKEN\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if obj.contains_key(\"ANTHROPIC_API_KEY\") {\n obj.insert(\n \"ANTHROPIC_API_KEY\".to_string(),\n json!(token),\n );\n updated = true;\n }\n if !updated {\n obj.insert(token_key.to_string(), json!(token));\n }\n } else {\n obj.insert(token_key.to_string(), json!(token));\n }\n }\n None => {\n // 至少写入一份可用的 Token\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut()\n {\n root.insert(\n \"env\".to_string(),\n json!({ token_key: token }),\n );\n } else {\n log::warn!(\n \"Claude provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"claude\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Claude Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Claude Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n }\n AppType::Codex => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Codex)\n .map_err(|e| format!(\"获取 Codex 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"codex\")\n {\n if let Some(token) = live_config\n .get(\"auth\")\n .and_then(|v| v.get(\"OPENAI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(auth_obj) = provider\n .settings_config\n .get_mut(\"auth\")\n .and_then(|v| v.as_object_mut())\n {\n auth_obj.insert(\"OPENAI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"auth\".to_string(),\n json!({ \"OPENAI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Codex provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"codex\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Codex Token 到数据库失败: {e}\");\n } else {\n log::info!(\"已同步 Codex Token 到数据库 (provider: {provider_id})\");\n }\n }\n }\n }\n }\n AppType::Gemini => {\n let provider_id =\n crate::settings::get_effective_current_provider(&self.db, &AppType::Gemini)\n .map_err(|e| format!(\"获取 Gemini 当前供应商失败: {e}\"))?;\n\n if let Some(provider_id) = provider_id {\n if let Ok(Some(mut provider)) =\n self.db.get_provider_by_id(&provider_id, \"gemini\")\n {\n if let Some(token) = live_config\n .get(\"env\")\n .and_then(|v| v.get(\"GEMINI_API_KEY\"))\n .and_then(|v| v.as_str())\n .map(|s| s.trim())\n .filter(|s| !s.is_empty() && *s != PROXY_TOKEN_PLACEHOLDER)\n {\n if let Some(env_obj) = provider\n .settings_config\n .get_mut(\"env\")\n .and_then(|v| v.as_object_mut())\n {\n env_obj.insert(\"GEMINI_API_KEY\".to_string(), json!(token));\n } else {\n if provider.settings_config.is_null() {\n provider.settings_config = json!({});\n }\n\n if let Some(root) = provider.settings_config.as_object_mut() {\n root.insert(\n \"env\".to_string(),\n json!({ \"GEMINI_API_KEY\": token }),\n );\n } else {\n log::warn!(\n \"Gemini provider settings_config 格式异常(非对象),跳过写入 Token (provider: {provider_id})\"\n );\n }\n }\n\n if let Err(e) = self.db.update_provider_settings_config(\n \"gemini\",\n &provider_id,\n &provider.settings_config,\n ) {\n log::warn!(\"同步 Gemini Token 到数据库失败: {e}\");\n } else {\n log::info!(\n \"已同步 Gemini Token 到数据库 (provider: {provider_id})\"\n );\n }\n }\n }\n }\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features, skip silently\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n async fn sync_live_to_providers(&self) -> Result<(), String> {\n if let Ok(live_config) = self.read_claude_live() {\n self.sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_codex_live() {\n self.sync_live_config_to_provider(&AppType::Codex, &live_config)\n .await?;\n }\n\n if let Ok(live_config) = self.read_gemini_live() {\n self.sync_live_config_to_provider(&AppType::Gemini, &live_config)\n .await?;\n }\n\n log::info!(\"Live 配置 Token 同步完成\");\n Ok(())\n }\n\n /// 停止代理服务器\n pub async fn stop(&self) -> Result<(), String> {\n if let Some(server) = self.server.write().await.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"停止代理服务器失败: {e}\"))?;\n\n // 停止时设置 proxy_enabled = false\n let mut global_config = self\n .db\n .get_global_proxy_config()\n .await\n .map_err(|e| format!(\"获取全局代理配置失败: {e}\"))?;\n\n if global_config.proxy_enabled {\n global_config.proxy_enabled = false;\n if let Err(e) = self.db.update_global_proxy_config(global_config).await {\n log::warn!(\"更新代理总开关失败: {e}\");\n }\n }\n\n log::info!(\"代理服务器已停止\");\n Ok(())\n } else {\n Err(\"代理服务器未运行\".to_string())\n }\n }\n\n /// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)\n ///\n /// 会清除 settings 表中的代理状态,下次启动不会自动恢复。\n pub async fn stop_with_restore(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 清除 proxy_config 表中的接管状态(兼容旧版)\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)\n for app_type in [\"claude\", \"codex\", \"gemini\"] {\n if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {\n if config.enabled {\n config.enabled = false;\n if let Err(e) = self.db.update_proxy_config_for_app(config).await {\n log::warn!(\"清除 {app_type} enabled 状态失败: {e}\");\n }\n }\n }\n }\n\n // 5. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 6. 重置健康状态(让健康徽章恢复为正常)\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n // 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用\n log::info!(\"代理已停止,Live 配置已恢复\");\n Ok(())\n }\n\n /// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)\n ///\n /// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复\n pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {\n // 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)\n if let Err(e) = self.stop().await {\n log::warn!(\"停止代理服务器失败(将继续恢复 Live 配置): {e}\");\n }\n\n // 2. 恢复原始 Live 配置\n self.restore_live_configs().await?;\n\n // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)\n // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复\n if let Ok(mut config) = self.db.get_proxy_config().await {\n config.live_takeover_active = false;\n let _ = self.db.update_proxy_config(config).await;\n }\n\n // 4. 删除备份(Live 配置已恢复,备份不再需要)\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n // 5. 重置健康状态\n self.db\n .clear_all_provider_health()\n .await\n .map_err(|e| format!(\"重置健康状态失败: {e}\"))?;\n\n log::info!(\"代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)\");\n Ok(())\n }\n\n /// 备份各应用的 Live 配置\n async fn backup_live_configs(&self) -> Result<(), String> {\n // Claude\n if let Ok(config) = self.read_claude_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"claude\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Claude 配置失败: {e}\"))?;\n }\n\n // Codex\n if let Ok(config) = self.read_codex_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"codex\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Codex 配置失败: {e}\"))?;\n }\n\n // Gemini\n if let Ok(config) = self.read_gemini_live() {\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?;\n self.db\n .save_live_backup(\"gemini\", &json_str)\n .await\n .map_err(|e| format!(\"备份 Gemini 配置失败: {e}\"))?;\n }\n\n log::info!(\"已备份所有应用的 Live 配置\");\n Ok(())\n }\n\n /// 备份指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn backup_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (app_type_str, config) = match app_type {\n AppType::Claude => (\"claude\", self.read_claude_live()?),\n AppType::Codex => (\"codex\", self.read_codex_live()?),\n AppType::Gemini => (\"gemini\", self.read_gemini_live()?),\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n return Err(\"OpenCode 不支持代理功能\".to_string());\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n return Err(\"OpenClaw 不支持代理功能\".to_string());\n }\n };\n\n let json_str = serde_json::to_string(&config)\n .map_err(|e| format!(\"序列化 {app_type_str} 配置失败: {e}\"))?;\n self.db\n .save_live_backup(app_type_str, &json_str)\n .await\n .map_err(|e| format!(\"备份 {app_type_str} 配置失败: {e}\"))?;\n\n Ok(())\n }\n\n /// 构造写入 Live 的代理地址(处理 0.0.0.0 / IPv6 等特殊情况)\n async fn build_proxy_urls(&self) -> Result<(String, String), String> {\n let config = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // listen_address 可能是 0.0.0.0(用于监听所有网卡),但客户端无法用 0.0.0.0 连接;\n // 因此写回到各应用配置时,优先使用本机回环地址。\n let connect_host = match config.listen_address.as_str() {\n \"0.0.0.0\" => \"127.0.0.1\".to_string(),\n \"::\" => \"::1\".to_string(),\n _ => config.listen_address.clone(),\n };\n let connect_host_for_url = if connect_host.contains(':') && !connect_host.starts_with('[') {\n format!(\"[{connect_host}]\")\n } else {\n connect_host\n };\n\n let proxy_origin = format!(\"http://{}:{}\", connect_host_for_url, config.listen_port);\n let proxy_url = proxy_origin.clone();\n let proxy_codex_base_url = format!(\"{}/v1\", proxy_origin.trim_end_matches('/'));\n\n Ok((proxy_url, proxy_codex_base_url))\n }\n\n /// 接管各应用的 Live 配置(写入代理地址)\n ///\n /// 代理服务器的路由已经根据 API 端点自动区分应用类型:\n /// - `/v1/messages` → Claude\n /// - `/v1/chat/completions`, `/v1/responses` → Codex\n /// - `/v1beta/*` → Gemini\n ///\n /// 因此不需要在 URL 中添加应用前缀。\n async fn takeover_live_configs(&self) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n // Claude: 修改 ANTHROPIC_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n // Codex: 修改 config.toml 的 base_url,auth.json 的 OPENAI_API_KEY(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_codex_live() {\n // 1. 修改 auth.json 中的 OPENAI_API_KEY(使用占位符)\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n // 2. 修改 config.toml 中的 base_url\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n\n // Gemini: 修改 GOOGLE_GEMINI_BASE_URL,使用占位符替代真实 Token(代理会注入真实 Token)\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n // 使用占位符,避免显示缺少 key 的警告\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(严格模式:目标配置不存在则返回错误)\n async fn takeover_live_config_strict(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n let mut live_config = self.read_claude_live()?;\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n self.write_claude_live(&live_config)?;\n log::info!(\"Claude Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::Codex => {\n let mut live_config = self.read_codex_live()?;\n\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config = Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n self.write_codex_live(&live_config)?;\n log::info!(\"Codex Live 配置已接管,代理地址: {proxy_codex_base_url}\");\n }\n AppType::Gemini => {\n let mut live_config = self.read_gemini_live()?;\n\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n self.write_gemini_live(&live_config)?;\n log::info!(\"Gemini Live 配置已接管,代理地址: {proxy_url}\");\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n return Err(\"OpenCode 不支持代理功能\".to_string());\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n return Err(\"OpenClaw 不支持代理功能\".to_string());\n }\n }\n\n Ok(())\n }\n\n /// 接管指定应用的 Live 配置(尽力而为:配置不存在/读取失败则跳过)\n async fn takeover_live_config_best_effort(&self, app_type: &AppType) -> Result<(), String> {\n let (proxy_url, proxy_codex_base_url) = self.build_proxy_urls().await?;\n\n match app_type {\n AppType::Claude => {\n if let Ok(mut live_config) = self.read_claude_live() {\n Self::apply_claude_takeover_fields(&mut live_config, &proxy_url);\n let _ = self.write_claude_live(&live_config);\n }\n }\n AppType::Codex => {\n if let Ok(mut live_config) = self.read_codex_live() {\n if let Some(auth) = live_config.get_mut(\"auth\").and_then(|v| v.as_object_mut())\n {\n auth.insert(\"OPENAI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n }\n\n let config_str = live_config\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let updated_config =\n Self::update_toml_base_url(config_str, &proxy_codex_base_url);\n live_config[\"config\"] = json!(updated_config);\n\n let _ = self.write_codex_live(&live_config);\n }\n }\n AppType::Gemini => {\n if let Ok(mut live_config) = self.read_gemini_live() {\n if let Some(env) = live_config.get_mut(\"env\").and_then(|v| v.as_object_mut()) {\n env.insert(\"GOOGLE_GEMINI_BASE_URL\".to_string(), json!(&proxy_url));\n env.insert(\"GEMINI_API_KEY\".to_string(), json!(PROXY_TOKEN_PLACEHOLDER));\n } else {\n live_config[\"env\"] = json!({\n \"GOOGLE_GEMINI_BASE_URL\": &proxy_url,\n \"GEMINI_API_KEY\": PROXY_TOKEN_PLACEHOLDER\n });\n }\n\n let _ = self.write_gemini_live(&live_config);\n }\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features, skip silently\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n /// 恢复指定应用的 Live 配置(若无备份则不做任何操作)\n async fn restore_live_config_for_app(&self, app_type: &AppType) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_inner(app_type).await\n }\n\n async fn restore_live_config_for_app_inner(&self, app_type: &AppType) -> Result<(), String> {\n match app_type {\n AppType::Claude => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"claude\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Claude 备份失败: {e}\"))?;\n self.write_claude_live(&config)?;\n log::info!(\"Claude Live 配置已恢复\");\n }\n }\n AppType::Codex => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"codex\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Codex 备份失败: {e}\"))?;\n self.write_codex_live(&config)?;\n log::info!(\"Codex Live 配置已恢复\");\n }\n }\n AppType::Gemini => {\n if let Ok(Some(backup)) = self.db.get_live_backup(\"gemini\").await {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 Gemini 备份失败: {e}\"))?;\n self.write_gemini_live(&config)?;\n log::info!(\"Gemini Live 配置已恢复\");\n }\n }\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features, skip silently\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features, skip silently\n }\n }\n\n Ok(())\n }\n\n /// 恢复原始 Live 配置\n async fn restore_live_configs(&self) -> Result<(), String> {\n let mut errors = Vec::new();\n\n for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {\n if let Err(e) = self\n .restore_live_config_for_app_with_fallback(&app_type)\n .await\n {\n errors.push(e);\n }\n }\n\n if errors.is_empty() {\n Ok(())\n } else {\n Err(errors.join(\";\"))\n }\n }\n\n async fn restore_live_config_for_app_with_fallback(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type.as_str()).await;\n self.restore_live_config_for_app_with_fallback_inner(app_type)\n .await\n }\n\n async fn restore_live_config_for_app_with_fallback_inner(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n let app_type_str = app_type.as_str();\n\n // 1) 优先从 Live 备份恢复(这是\"原始 Live\"的唯一可靠来源)\n let backup = self\n .db\n .get_live_backup(app_type_str)\n .await\n .map_err(|e| format!(\"获取 {app_type_str} Live 备份失败: {e}\"))?;\n if let Some(backup) = backup {\n let config: Value = serde_json::from_str(&backup.original_config)\n .map_err(|e| format!(\"解析 {app_type_str} 备份失败: {e}\"))?;\n self.write_live_config_for_app(app_type, &config)?;\n log::info!(\"{app_type_str} Live 配置已从备份恢复\");\n return Ok(());\n }\n\n // 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)\n if !self.detect_takeover_in_live_config_for_app(app_type) {\n return Ok(());\n }\n\n // 2.1) 优先从 SSOT(当前供应商)重建 Live(比\"清理字段\"更可用)\n match self.restore_live_from_ssot_for_app(app_type) {\n Ok(true) => {\n log::info!(\"{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)\");\n return Ok(());\n }\n Ok(false) => {\n log::warn!(\n \"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符\"\n );\n }\n Err(e) => {\n log::error!(\n \"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}\"\n );\n }\n }\n\n // 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态\n self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;\n log::info!(\"{app_type_str} Live 接管占位符已清理(无备份兜底)\");\n Ok(())\n }\n\n fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.write_claude_live(config),\n AppType::Codex => self.write_codex_live(config),\n AppType::Gemini => self.write_gemini_live(config),\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n Err(\"OpenCode 不支持代理功能\".to_string())\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n Err(\"OpenClaw 不支持代理功能\".to_string())\n }\n }\n }\n\n pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {\n match app_type {\n AppType::Claude => match self.read_claude_live() {\n Ok(config) => Self::is_claude_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Codex => match self.read_codex_live() {\n Ok(config) => Self::is_codex_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::Gemini => match self.read_gemini_live() {\n Ok(config) => Self::is_gemini_live_taken_over(&config),\n Err(_) => false,\n },\n AppType::OpenCode => {\n // OpenCode doesn't support proxy takeover\n false\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy takeover\n false\n }\n }\n }\n\n /// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。\n ///\n /// 返回值:\n /// - Ok(true):已成功写回\n /// - Ok(false):缺少当前供应商/供应商不存在,无法写回\n fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {\n let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)\n .map_err(|e| format!(\"获取 {app_type:?} 当前供应商失败: {e}\"))?;\n\n let Some(current_id) = current_id else {\n return Ok(false);\n };\n\n let providers = self\n .db\n .get_all_providers(app_type.as_str())\n .map_err(|e| format!(\"读取 {app_type:?} 供应商列表失败: {e}\"))?;\n\n let Some(provider) = providers.get(&current_id) else {\n return Ok(false);\n };\n\n write_live_with_common_config(self.db.as_ref(), app_type, provider)\n .map_err(|e| format!(\"写入 {app_type:?} Live 配置失败: {e}\"))?;\n\n Ok(true)\n }\n\n fn cleanup_takeover_placeholders_in_live_for_app(\n &self,\n app_type: &AppType,\n ) -> Result<(), String> {\n match app_type {\n AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),\n AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),\n AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),\n AppType::OpenCode => {\n // OpenCode doesn't support proxy features\n Ok(())\n }\n AppType::OpenClaw => {\n // OpenClaw doesn't support proxy features\n Ok(())\n }\n }\n }\n\n fn is_local_proxy_url(url: &str) -> bool {\n let url = url.trim();\n if !url.starts_with(\"http://\") {\n return false;\n }\n let rest = &url[\"http://\".len()..];\n rest.starts_with(\"127.0.0.1\")\n || rest.starts_with(\"localhost\")\n || rest.starts_with(\"0.0.0.0\")\n || rest.starts_with(\"[::1]\")\n || rest.starts_with(\"[::]\")\n || rest.starts_with(\"::1\")\n || rest.starts_with(\"::\")\n }\n\n fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_claude_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(key);\n }\n }\n\n if env\n .get(\"ANTHROPIC_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"ANTHROPIC_BASE_URL\");\n }\n\n self.write_claude_live(&config)?;\n Ok(())\n }\n\n fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_codex_live()?;\n\n if let Some(auth) = config.get_mut(\"auth\").and_then(|v| v.as_object_mut()) {\n if auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n {\n auth.remove(\"OPENAI_API_KEY\");\n }\n }\n\n if let Some(cfg_str) = config.get(\"config\").and_then(|v| v.as_str()) {\n let updated = Self::remove_local_toml_base_url(cfg_str);\n config[\"config\"] = json!(updated);\n }\n\n self.write_codex_live(&config)?;\n Ok(())\n }\n\n /// Remove local proxy base_url from TOML(委托给 codex_config 共享实现)\n fn remove_local_toml_base_url(toml_str: &str) -> String {\n crate::codex_config::remove_codex_toml_base_url_if(toml_str, Self::is_local_proxy_url)\n }\n\n fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {\n let mut config = self.read_gemini_live()?;\n\n let Some(env) = config.get_mut(\"env\").and_then(|v| v.as_object_mut()) else {\n return Ok(());\n };\n\n if env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n env.remove(\"GEMINI_API_KEY\");\n }\n\n if env\n .get(\"GOOGLE_GEMINI_BASE_URL\")\n .and_then(|v| v.as_str())\n .map(Self::is_local_proxy_url)\n .unwrap_or(false)\n {\n env.remove(\"GOOGLE_GEMINI_BASE_URL\");\n }\n\n self.write_gemini_live(&config)?;\n Ok(())\n }\n\n /// 检查是否处于 Live 接管模式\n pub async fn is_takeover_active(&self) -> Result<bool, String> {\n let status = self.get_takeover_status().await?;\n Ok(status.claude || status.codex || status.gemini)\n }\n\n /// 从异常退出中恢复(启动时调用)\n ///\n /// 检测到 Live 备份残留时调用此方法。\n /// 会恢复 Live 配置、清除接管标志、删除备份。\n pub async fn recover_from_crash(&self) -> Result<(), String> {\n // 1. 恢复 Live 配置\n self.restore_live_configs().await?;\n\n // 2. 清除接管标志\n self.db\n .set_live_takeover_active(false)\n .await\n .map_err(|e| format!(\"清除接管状态失败: {e}\"))?;\n\n // 3. 删除备份\n self.db\n .delete_all_live_backups()\n .await\n .map_err(|e| format!(\"删除备份失败: {e}\"))?;\n\n log::info!(\"已从异常退出中恢复 Live 配置\");\n Ok(())\n }\n\n /// 检测 Live 配置是否处于\"被接管\"的残留状态\n ///\n /// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,\n /// 启动流程可以据此触发恢复逻辑。\n pub fn detect_takeover_in_live_configs(&self) -> bool {\n if let Ok(config) = self.read_claude_live() {\n if Self::is_claude_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_codex_live() {\n if Self::is_codex_live_taken_over(&config) {\n return true;\n }\n }\n\n if let Ok(config) = self.read_gemini_live() {\n if Self::is_gemini_live_taken_over(&config) {\n return true;\n }\n }\n\n false\n }\n\n fn is_claude_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n\n for key in [\n \"ANTHROPIC_AUTH_TOKEN\",\n \"ANTHROPIC_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n ] {\n if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {\n return true;\n }\n }\n\n false\n }\n\n fn is_codex_live_taken_over(config: &Value) -> bool {\n let auth = match config.get(\"auth\").and_then(|v| v.as_object()) {\n Some(auth) => auth,\n None => return false,\n };\n auth.get(\"OPENAI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n fn is_gemini_live_taken_over(config: &Value) -> bool {\n let env = match config.get(\"env\").and_then(|v| v.as_object()) {\n Some(env) => env,\n None => return false,\n };\n env.get(\"GEMINI_API_KEY\").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)\n }\n\n /// 从供应商配置更新 Live 备份(用于代理模式下的热切换)\n ///\n /// 与 backup_live_configs() 不同,此方法从供应商的 settings_config 生成备份,\n /// 而不是从 Live 文件读取(因为 Live 文件已被代理接管)。\n pub async fn update_live_backup_from_provider(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n self.update_live_backup_from_provider_inner(app_type, provider)\n .await\n }\n\n /// 仅供已持有 per-app 切换锁的调用方使用。\n async fn update_live_backup_from_provider_inner(\n &self,\n app_type: &str,\n provider: &Provider,\n ) -> Result<(), String> {\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"未知的应用类型: {app_type}\"))?;\n let mut effective_settings =\n build_effective_settings_with_common_config(self.db.as_ref(), &app_type_enum, provider)\n .map_err(|e| format!(\"构建 {app_type} 有效配置失败: {e}\"))?;\n\n if matches!(app_type_enum, AppType::Codex) {\n let existing_backup = self\n .db\n .get_live_backup(app_type)\n .await\n .map_err(|e| format!(\"读取 {app_type} 现有备份失败: {e}\"))?;\n\n if let Some(existing_backup) = existing_backup {\n let existing_value: Value = serde_json::from_str(&existing_backup.original_config)\n .map_err(|e| format!(\"解析 {app_type} 现有备份失败: {e}\"))?;\n Self::preserve_codex_mcp_servers_in_backup(\n &mut effective_settings,\n &existing_value,\n )?;\n }\n }\n\n let backup_json = match app_type_enum {\n AppType::Claude => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Claude 配置失败: {e}\"))?,\n AppType::Codex => serde_json::to_string(&effective_settings)\n .map_err(|e| format!(\"序列化 Codex 配置失败: {e}\"))?,\n AppType::Gemini => {\n // Gemini takeover 仅修改 .env;settings.json(含 mcpServers)保持原样。\n let env_backup = if let Some(env) = effective_settings.get(\"env\") {\n json!({ \"env\": env })\n } else {\n json!({ \"env\": {} })\n };\n serde_json::to_string(&env_backup)\n .map_err(|e| format!(\"序列化 Gemini 配置失败: {e}\"))?\n }\n AppType::OpenCode | AppType::OpenClaw => {\n return Err(format!(\"未知的应用类型: {app_type}\"));\n }\n };\n\n self.db\n .save_live_backup(app_type, &backup_json)\n .await\n .map_err(|e| format!(\"更新 {app_type} 备份失败: {e}\"))?;\n\n log::info!(\"已更新 {app_type} Live 备份(热切换)\");\n Ok(())\n }\n\n pub async fn hot_switch_provider(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<HotSwitchOutcome, String> {\n let _guard = self.switch_locks.lock_for_app(app_type).await;\n\n let app_type_enum =\n AppType::from_str(app_type).map_err(|_| format!(\"无效的应用类型: {app_type}\"))?;\n let provider = self\n .db\n .get_provider_by_id(provider_id, app_type)\n .map_err(|e| format!(\"读取供应商失败: {e}\"))?\n .ok_or_else(|| format!(\"供应商不存在: {provider_id}\"))?;\n\n let logical_target_changed =\n crate::settings::get_effective_current_provider(&self.db, &app_type_enum)\n .map_err(|e| format!(\"读取当前供应商失败: {e}\"))?\n .as_deref()\n != Some(provider_id);\n\n let has_backup = self\n .db\n .get_live_backup(app_type_enum.as_str())\n .await\n .map_err(|e| format!(\"读取 {app_type} 备份失败: {e}\"))?\n .is_some();\n let live_taken_over = self.detect_takeover_in_live_config_for_app(&app_type_enum);\n let should_sync_backup = has_backup || live_taken_over;\n\n self.db\n .set_current_provider(app_type_enum.as_str(), provider_id)\n .map_err(|e| format!(\"更新当前供应商失败: {e}\"))?;\n crate::settings::set_current_provider(&app_type_enum, Some(provider_id))\n .map_err(|e| format!(\"更新本地当前供应商失败: {e}\"))?;\n\n if should_sync_backup {\n self.update_live_backup_from_provider_inner(app_type, &provider)\n .await?;\n\n if matches!(app_type_enum, AppType::Claude) {\n self.sync_claude_live_from_provider_while_proxy_active(&provider)\n .await?;\n if let Err(e) = self.cleanup_claude_model_overrides_in_live() {\n log::warn!(\"清理 Claude Live 模型字段失败(不影响热切换结果): {e}\");\n }\n }\n }\n\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .set_active_target(app_type_enum.as_str(), &provider.id, &provider.name)\n .await;\n }\n\n Ok(HotSwitchOutcome {\n logical_target_changed,\n })\n }\n\n #[cfg(test)]\n async fn lock_switch_for_test(&self, app_type: &str) -> tokio::sync::OwnedMutexGuard<()> {\n self.switch_locks.lock_for_app(app_type).await\n }\n\n fn preserve_codex_mcp_servers_in_backup(\n target_settings: &mut Value,\n existing_backup: &Value,\n ) -> Result<(), String> {\n let target_obj = target_settings\n .as_object_mut()\n .ok_or_else(|| \"Codex 备份必须是 JSON 对象\".to_string())?;\n\n let target_config = target_obj\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n let mut target_doc = if target_config.trim().is_empty() {\n toml_edit::DocumentMut::new()\n } else {\n target_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析新的 Codex config.toml 失败: {e}\"))?\n };\n\n let existing_config = existing_backup\n .get(\"config\")\n .and_then(|v| v.as_str())\n .unwrap_or(\"\");\n if existing_config.trim().is_empty() {\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n return Ok(());\n }\n\n let existing_doc = existing_config\n .parse::<toml_edit::DocumentMut>()\n .map_err(|e| format!(\"解析现有 Codex 备份失败: {e}\"))?;\n\n if let Some(existing_mcp_servers) = existing_doc.get(\"mcp_servers\") {\n match target_doc.get_mut(\"mcp_servers\") {\n Some(target_mcp_servers) => {\n if let (Some(target_table), Some(existing_table)) = (\n target_mcp_servers.as_table_like_mut(),\n existing_mcp_servers.as_table_like(),\n ) {\n for (server_id, server_item) in existing_table.iter() {\n if target_table.get(server_id).is_none() {\n target_table.insert(server_id, server_item.clone());\n }\n }\n } else {\n log::warn!(\n \"Codex config contains a non-table mcp_servers section; skipping backup MCP merge\"\n );\n }\n }\n None => {\n target_doc[\"mcp_servers\"] = existing_mcp_servers.clone();\n }\n }\n }\n\n target_obj.insert(\"config\".to_string(), json!(target_doc.to_string()));\n Ok(())\n }\n\n /// 代理模式下切换供应商(热切换,不写 Live)\n pub async fn switch_proxy_target(\n &self,\n app_type: &str,\n provider_id: &str,\n ) -> Result<(), String> {\n let outcome = self.hot_switch_provider(app_type, provider_id).await?;\n\n if outcome.logical_target_changed {\n log::info!(\"代理模式:已切换 {app_type} 的目标供应商为 {provider_id}\");\n } else {\n log::debug!(\"代理模式:{app_type} 已对齐到目标供应商 {provider_id}\");\n }\n Ok(())\n }\n\n // ==================== Live 配置读写辅助方法 ====================\n\n /// 更新 TOML 字符串中的 base_url(委托给 codex_config 共享实现)\n fn update_toml_base_url(toml_str: &str, new_url: &str) -> String {\n crate::codex_config::update_codex_toml_field(toml_str, \"base_url\", new_url)\n .unwrap_or_else(|_| toml_str.to_string())\n }\n\n fn read_claude_live(&self) -> Result<Value, String> {\n let path = get_claude_settings_path();\n if !path.exists() {\n return Err(\"Claude 配置文件不存在\".to_string());\n }\n\n let mut value: Value =\n read_json_file(&path).map_err(|e| format!(\"读取 Claude 配置失败: {e}\"))?;\n\n if value.is_null() {\n value = json!({});\n }\n\n if !value.is_object() {\n let kind = match &value {\n Value::Null => \"null\",\n Value::Bool(_) => \"boolean\",\n Value::Number(_) => \"number\",\n Value::String(_) => \"string\",\n Value::Array(_) => \"array\",\n Value::Object(_) => \"object\",\n };\n return Err(format!(\n \"Claude 配置文件格式错误:根节点必须是 JSON 对象(当前为 {kind}),路径: {}\",\n path.display()\n ));\n }\n\n Ok(value)\n }\n\n fn write_claude_live(&self, config: &Value) -> Result<(), String> {\n let path = get_claude_settings_path();\n let settings = crate::services::provider::sanitize_claude_settings_for_live(config);\n write_json_file(&path, &settings).map_err(|e| format!(\"写入 Claude 配置失败: {e}\"))\n }\n\n fn read_codex_live(&self) -> Result<Value, String> {\n use crate::codex_config::{get_codex_auth_path, get_codex_config_path};\n\n let auth_path = get_codex_auth_path();\n if !auth_path.exists() {\n return Err(\"Codex auth.json 不存在\".to_string());\n }\n\n let auth: Value =\n read_json_file(&auth_path).map_err(|e| format!(\"读取 Codex auth 失败: {e}\"))?;\n\n let config_path = get_codex_config_path();\n let config_str = if config_path.exists() {\n std::fs::read_to_string(&config_path)\n .map_err(|e| format!(\"读取 Codex config 失败: {e}\"))?\n } else {\n String::new()\n };\n\n Ok(json!({\n \"auth\": auth,\n \"config\": config_str\n }))\n }\n\n fn write_codex_live(&self, config: &Value) -> Result<(), String> {\n use crate::codex_config::{\n get_codex_auth_path, get_codex_config_path, write_codex_live_atomic,\n };\n\n let auth = config.get(\"auth\");\n let config_str = config.get(\"config\").and_then(|v| v.as_str());\n\n match (auth, config_str) {\n (Some(auth), Some(cfg)) => write_codex_live_atomic(auth, Some(cfg))\n .map_err(|e| format!(\"写入 Codex 配置失败: {e}\"))?,\n (Some(auth), None) => {\n let auth_path = get_codex_auth_path();\n write_json_file(&auth_path, auth)\n .map_err(|e| format!(\"写入 Codex auth 失败: {e}\"))?;\n }\n (None, Some(cfg)) => {\n let config_path = get_codex_config_path();\n crate::config::write_text_file(&config_path, cfg)\n .map_err(|e| format!(\"写入 Codex config 失败: {e}\"))?;\n }\n (None, None) => {}\n }\n\n Ok(())\n }\n\n fn read_gemini_live(&self) -> Result<Value, String> {\n use crate::gemini_config::{env_to_json, get_gemini_env_path, read_gemini_env};\n\n let env_path = get_gemini_env_path();\n if !env_path.exists() {\n return Err(\"Gemini .env 文件不存在\".to_string());\n }\n\n let env_map = read_gemini_env().map_err(|e| format!(\"读取 Gemini env 失败: {e}\"))?;\n Ok(env_to_json(&env_map))\n }\n\n fn write_gemini_live(&self, config: &Value) -> Result<(), String> {\n use crate::gemini_config::{json_to_env, write_gemini_env_atomic};\n\n let env_map = json_to_env(config).map_err(|e| format!(\"转换 Gemini 配置失败: {e}\"))?;\n write_gemini_env_atomic(&env_map).map_err(|e| format!(\"写入 Gemini env 失败: {e}\"))?;\n Ok(())\n }\n\n // ==================== 原有方法 ====================\n\n /// 获取服务器状态\n pub async fn get_status(&self) -> Result<ProxyStatus, String> {\n if let Some(server) = self.server.read().await.as_ref() {\n Ok(server.get_status().await)\n } else {\n // 服务器未运行时返回默认状态\n Ok(ProxyStatus {\n running: false,\n ..Default::default()\n })\n }\n }\n\n /// 获取代理配置\n pub async fn get_config(&self) -> Result<ProxyConfig, String> {\n self.db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))\n }\n\n /// 更新代理配置\n pub async fn update_config(&self, config: &ProxyConfig) -> Result<(), String> {\n // 记录旧配置用于判定是否需要重启\n let previous = self\n .db\n .get_proxy_config()\n .await\n .map_err(|e| format!(\"获取代理配置失败: {e}\"))?;\n\n // 保存到数据库(保持 live_takeover_active 状态不变)\n let mut new_config = config.clone();\n new_config.live_takeover_active = previous.live_takeover_active;\n\n self.db\n .update_proxy_config(new_config.clone())\n .await\n .map_err(|e| format!(\"保存代理配置失败: {e}\"))?;\n\n // 检查服务器当前状态\n let mut server_guard = self.server.write().await;\n if server_guard.is_none() {\n return Ok(());\n }\n\n // 判断是否需要重启(地址或端口变更)\n let require_restart = new_config.listen_address != previous.listen_address\n || new_config.listen_port != previous.listen_port;\n\n if require_restart {\n if let Some(server) = server_guard.take() {\n server\n .stop()\n .await\n .map_err(|e| format!(\"重启前停止代理服务器失败: {e}\"))?;\n }\n\n let app_handle = self.app_handle.read().await.clone();\n let new_server = ProxyServer::new(new_config, self.db.clone(), app_handle);\n new_server\n .start()\n .await\n .map_err(|e| format!(\"重启代理服务器失败: {e}\"))?;\n\n *server_guard = Some(new_server);\n log::info!(\"代理配置已更新,服务器已自动重启应用最新配置\");\n\n // 如果当前存在任意 app 的 Live 接管,需要同步更新 Live 中的代理地址(否则客户端仍指向旧端口)\n drop(server_guard);\n if let Ok(takeover) = self.get_takeover_status().await {\n let mut updated_any = false;\n\n if takeover.claude {\n self.takeover_live_config_best_effort(&AppType::Claude)\n .await?;\n updated_any = true;\n }\n if takeover.codex {\n self.takeover_live_config_best_effort(&AppType::Codex)\n .await?;\n updated_any = true;\n }\n if takeover.gemini {\n self.takeover_live_config_best_effort(&AppType::Gemini)\n .await?;\n updated_any = true;\n }\n\n if updated_any {\n log::info!(\"已同步更新 Live 配置中的代理地址\");\n }\n }\n\n return Ok(());\n } else if let Some(server) = server_guard.as_ref() {\n server.apply_runtime_config(&new_config).await;\n log::info!(\"代理配置已实时应用,无需重启代理服务器\");\n }\n\n Ok(())\n }\n\n /// 检查服务器是否正在运行\n pub async fn is_running(&self) -> bool {\n self.server.read().await.is_some()\n }\n\n /// 热更新熔断器配置\n ///\n /// 如果代理服务器正在运行,将新配置应用到所有已创建的熔断器实例\n pub async fn update_circuit_breaker_configs(\n &self,\n config: crate::proxy::CircuitBreakerConfig,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server.update_circuit_breaker_configs(config).await;\n log::info!(\"已热更新运行中的熔断器配置\");\n } else {\n log::debug!(\"代理服务器未运行,熔断器配置将在下次启动时生效\");\n }\n Ok(())\n }\n\n /// 重置指定 Provider 的熔断器\n ///\n /// 如果代理服务器正在运行,立即重置内存中的熔断器状态\n pub async fn reset_provider_circuit_breaker(\n &self,\n provider_id: &str,\n app_type: &str,\n ) -> Result<(), String> {\n if let Some(server) = self.server.read().await.as_ref() {\n server\n .reset_provider_circuit_breaker(provider_id, app_type)\n .await;\n log::info!(\"已重置 Provider {provider_id} (app: {app_type}) 的熔断器\");\n }\n Ok(())\n }\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::provider::ProviderMeta;\n use serial_test::serial;\n use std::env;\n use tempfile::TempDir;\n\n struct TempHome {\n #[allow(dead_code)]\n dir: TempDir,\n original_home: 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 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 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 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 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 #[test]\n fn update_toml_base_url_updates_active_model_provider_base_url() {\n let input = r#\"\nmodel_provider = \"any\"\nmodel = \"gpt-5.1-codex\"\ndisable_response_storage = true\n\n[model_providers.any]\nname = \"any\"\nbase_url = \"https://anyrouter.top/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = true\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"base_url\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.base_url should exist\");\n\n assert_eq!(base_url, new_url);\n assert!(\n parsed.get(\"base_url\").is_none(),\n \"should not write top-level base_url\"\n );\n\n let wire_api = parsed\n .get(\"model_providers\")\n .and_then(|v| v.get(\"any\"))\n .and_then(|v| v.get(\"wire_api\"))\n .and_then(|v| v.as_str())\n .expect(\"model_providers.any.wire_api should exist\");\n assert_eq!(wire_api, \"responses\");\n }\n\n #[test]\n fn update_toml_base_url_falls_back_to_top_level_base_url() {\n let input = r#\"\nmodel = \"gpt-5.1-codex\"\n\"#;\n\n let new_url = \"http://127.0.0.1:5000/v1\";\n let output = ProxyService::update_toml_base_url(input, new_url);\n\n let parsed: toml::Value =\n toml::from_str(&output).expect(\"updated config should be valid TOML\");\n\n let base_url = parsed\n .get(\"base_url\")\n .and_then(|v| v.as_str())\n .expect(\"base_url should exist\");\n\n assert_eq!(base_url, new_url);\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_does_not_add_anthropic_api_key() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_AUTH_TOKEN\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_AUTH_TOKEN\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_API_KEY\"),\n \"should not add ANTHROPIC_API_KEY when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn sync_claude_token_respects_existing_api_key_field() {\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 service = ProxyService::new(db.clone());\n\n let provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"https://api.anthropic.com\",\n \"ANTHROPIC_API_KEY\": \"stale\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider)\n .expect(\"save provider\");\n db.set_current_provider(\"claude\", \"p1\")\n .expect(\"set current provider\");\n\n let live_config = json!({\n \"env\": {\n \"ANTHROPIC_AUTH_TOKEN\": \"fresh\"\n }\n });\n\n service\n .sync_live_config_to_provider(&AppType::Claude, &live_config)\n .await\n .expect(\"sync\");\n\n let updated = db\n .get_provider_by_id(\"p1\", \"claude\")\n .expect(\"get provider\")\n .expect(\"provider exists\");\n let env = updated\n .settings_config\n .get(\"env\")\n .and_then(|v| v.as_object())\n .expect(\"env object\");\n\n assert_eq!(\n env.get(\"ANTHROPIC_API_KEY\").and_then(|v| v.as_str()),\n Some(\"fresh\")\n );\n assert!(\n !env.contains_key(\"ANTHROPIC_AUTH_TOKEN\"),\n \"should not add ANTHROPIC_AUTH_TOKEN when absent\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn switch_proxy_target_updates_live_backup_when_taken_over() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\"\n }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\"\n }\n }),\n None,\n );\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n\n // 模拟\"已接管\"状态:存在 Live 备份(内容不重要,会被热切换更新)\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n service\n .switch_proxy_target(\"claude\", \"b\")\n .await\n .expect(\"switch proxy target\");\n\n // 断言:本地 settings 的 current provider 已同步\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"b\")\n );\n\n // 断言:Live 备份已更新为目标供应商配置(用于 stop_with_restore 恢复)\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_updates_claude_live_while_preserving_takeover_fields() {\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 service = ProxyService::new(db.clone());\n\n let provider_a = Provider::with_id(\n \"a\".to_string(),\n \"A\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"a-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.a.example\",\n \"ANTHROPIC_MODEL\": \"claude-old\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }),\n None,\n );\n let provider_b = Provider::with_id(\n \"b\".to_string(),\n \"B\".to_string(),\n json!({\n \"env\": {\n \"ANTHROPIC_API_KEY\": \"b-key\",\n \"ANTHROPIC_BASE_URL\": \"https://api.b.example\",\n \"ANTHROPIC_MODEL\": \"claude-new\"\n },\n \"permissions\": { \"allow\": [\"Read\"] }\n }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({\n \"env\": {\n \"ANTHROPIC_BASE_URL\": \"http://127.0.0.1:15721\",\n \"ANTHROPIC_API_KEY\": PROXY_TOKEN_PLACEHOLDER,\n \"ANTHROPIC_MODEL\": \"stale-model\"\n },\n \"permissions\": { \"allow\": [\"Bash\"] }\n }))\n .expect(\"seed taken-over live file\");\n\n service\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"hot switch provider\");\n\n let live = service.read_claude_live().expect(\"read live config\");\n assert_eq!(\n live.get(\"permissions\"),\n provider_b.settings_config.get(\"permissions\"),\n \"provider-derived live settings should be refreshed\"\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_TOKEN_PLACEHOLDER),\n \"takeover token placeholder should be preserved\"\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(\"http://127.0.0.1:15721\"),\n \"takeover proxy URL should remain active\"\n );\n assert!(\n live.get(\"env\")\n .and_then(|env| env.get(\"ANTHROPIC_MODEL\"))\n .is_none(),\n \"Claude model override fields should be removed in takeover mode\"\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn hot_switch_provider_serializes_same_app_switches() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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 let provider_c = Provider::with_id(\n \"c\".to_string(),\n \"C\".to_string(),\n json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"c-key\" } }),\n None,\n );\n\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.save_provider(\"claude\", &provider_c)\n .expect(\"save provider c\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\"claude\", \"{\\\"env\\\":{}}\")\n .await\n .expect(\"seed live backup\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_b = service.clone();\n let service_for_c = service.clone();\n\n let switch_b = tokio::spawn(async move {\n service_for_b\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let switch_c = tokio::spawn(async move {\n service_for_c\n .hot_switch_provider(\"claude\", \"c\")\n .await\n .expect(\"switch to c\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome_b = switch_b.await.expect(\"join switch b\");\n let outcome_c = switch_c.await.expect(\"join switch c\");\n assert!(outcome_b.logical_target_changed);\n assert!(outcome_c.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"c\".to_string())\n );\n assert_eq!(\n crate::settings::get_current_provider(&AppType::Claude).as_deref(),\n Some(\"c\")\n );\n assert_eq!(\n db.get_current_provider(\"claude\").expect(\"db current\"),\n Some(\"c\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_c.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n }\n\n #[tokio::test]\n #[serial]\n async fn restore_waits_for_hot_switch_and_restores_latest_backup() {\n use tokio::time::{sleep, Duration};\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 service = ProxyService::new(db.clone());\n\n let 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 let 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\n db.save_provider(\"claude\", &provider_a)\n .expect(\"save provider a\");\n db.save_provider(\"claude\", &provider_b)\n .expect(\"save provider b\");\n db.set_current_provider(\"claude\", \"a\")\n .expect(\"set current provider\");\n crate::settings::set_current_provider(&AppType::Claude, Some(\"a\"))\n .expect(\"set local current provider\");\n db.save_live_backup(\n \"claude\",\n &serde_json::to_string(&provider_a.settings_config).expect(\"serialize provider a\"),\n )\n .await\n .expect(\"seed live backup\");\n service\n .write_claude_live(&json!({ \"env\": { \"ANTHROPIC_API_KEY\": \"stale\" } }))\n .expect(\"seed live file\");\n\n let guard = service.lock_switch_for_test(\"claude\").await;\n let service_for_switch = service.clone();\n let service_for_restore = service.clone();\n\n let switch_to_b = tokio::spawn(async move {\n service_for_switch\n .hot_switch_provider(\"claude\", \"b\")\n .await\n .expect(\"switch to b\")\n });\n sleep(Duration::from_millis(20)).await;\n let restore = tokio::spawn(async move {\n service_for_restore\n .restore_live_config_for_app_with_fallback(&AppType::Claude)\n .await\n .expect(\"restore claude live\")\n });\n\n sleep(Duration::from_millis(20)).await;\n drop(guard);\n\n let outcome = switch_to_b.await.expect(\"join switch\");\n restore.await.expect(\"join restore\");\n assert!(outcome.logical_target_changed);\n\n assert_eq!(\n crate::settings::get_effective_current_provider(&db, &AppType::Claude)\n .expect(\"effective current\"),\n Some(\"b\".to_string())\n );\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let expected = serde_json::to_string(&provider_b.settings_config).expect(\"serialize\");\n assert_eq!(backup.original_config, expected);\n assert_eq!(\n service.read_claude_live().expect(\"read live\"),\n provider_b.settings_config\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_claude_common_config() {\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 db.set_config_snippet(\n \"claude\",\n Some(\n serde_json::json!({\n \"includeCoAuthoredBy\": false\n })\n .to_string(),\n ),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\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 common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"claude\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"claude\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n\n assert_eq!(\n stored.get(\"includeCoAuthoredBy\").and_then(|v| v.as_bool()),\n Some(false),\n \"common config should be applied into Claude restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_applies_codex_common_config() {\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 db.set_config_snippet(\n \"codex\",\n Some(\"disable_response_storage = true\\n\".to_string()),\n )\n .expect(\"set common config snippet\");\n\n let service = ProxyService::new(db.clone());\n\n let mut provider = Provider::with_id(\n \"p1\".to_string(),\n \"P1\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://codex.example/v1\"\n\"#\n }),\n None,\n );\n provider.meta = Some(ProviderMeta {\n common_config_enabled: Some(true),\n ..Default::default()\n });\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"disable_response_storage = true\"),\n \"common config should be applied into Codex restore backup\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_preserves_codex_mcp_servers() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-4\"\n\n[model_providers.any]\nbase_url = \"https://old.example/v1\"\n\n[mcp_servers.echo]\ncommand = \"npx\"\nargs = [\"echo-server\"]\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"model_provider = \"any\"\nmodel = \"gpt-5\"\n\n[model_providers.any]\nbase_url = \"https://new.example/v1\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n\n assert!(\n config.contains(\"[mcp_servers.echo]\"),\n \"existing Codex MCP section should survive proxy hot-switch backup update\"\n );\n assert!(\n config.contains(\"https://new.example/v1\"),\n \"provider-specific base_url should still update to the new provider\"\n );\n }\n\n #[tokio::test]\n #[serial]\n async fn update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict() {\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 service = ProxyService::new(db.clone());\n\n db.save_live_backup(\n \"codex\",\n &serde_json::to_string(&json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"old-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"old-command\"\n\n[mcp_servers.legacy]\ncommand = \"legacy-command\"\n\"#\n }))\n .expect(\"serialize seed backup\"),\n )\n .await\n .expect(\"seed live backup\");\n\n let provider = Provider::with_id(\n \"p2\".to_string(),\n \"P2\".to_string(),\n json!({\n \"auth\": {\n \"OPENAI_API_KEY\": \"new-token\"\n },\n \"config\": r#\"[mcp_servers.shared]\ncommand = \"new-command\"\n\n[mcp_servers.latest]\ncommand = \"latest-command\"\n\"#\n }),\n None,\n );\n\n service\n .update_live_backup_from_provider(\"codex\", &provider)\n .await\n .expect(\"update live backup\");\n\n let backup = db\n .get_live_backup(\"codex\")\n .await\n .expect(\"get live backup\")\n .expect(\"backup exists\");\n let stored: Value =\n serde_json::from_str(&backup.original_config).expect(\"parse backup json\");\n let config = stored\n .get(\"config\")\n .and_then(|v| v.as_str())\n .expect(\"config string\");\n let parsed: toml::Value = toml::from_str(config).expect(\"parse merged codex config\");\n\n let mcp_servers = parsed\n .get(\"mcp_servers\")\n .expect(\"mcp_servers should be present\");\n assert_eq!(\n mcp_servers\n .get(\"shared\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"new-command\"),\n \"new provider/common-config MCP definition should win on conflict\"\n );\n assert_eq!(\n mcp_servers\n .get(\"legacy\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"legacy-command\"),\n \"backup-only MCP entries should still be preserved\"\n );\n assert_eq!(\n mcp_servers\n .get(\"latest\")\n .and_then(|v| v.get(\"command\"))\n .and_then(|v| v.as_str()),\n Some(\"latest-command\"),\n \"new MCP entries should remain in the restore backup\"\n );\n }\n}\n"}}, "src/i18n/locales/zh-TW.json": {"bab9119da3c9dbecaeca84f2a022c9b7ad8e52fe": {"t": 1785746536.7086694, "n": 94586, "h": "b0606bca34ab5f33", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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\": \"傳送前依內建模型名稱清單預測性地移除圖片。關閉後僅保留「已宣告純文字」與「上游錯誤兜底」兩條事實驅動路徑,避免內建清單將多模態模型誤判為純文字而靜默丟失圖片\"\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"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 \"sudocode\": \"SudoCode 是全球一流 AI 模型聚合服務,一個 API Key 可通用多模型,並支援 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "6692343d1e6c2a4297c83b0019b2628f30a1e480": {"t": 1785761113.442672, "n": 94586, "h": "b0606bca34ab5f33", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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\": \"傳送前依內建模型名稱清單預測性地移除圖片。關閉後僅保留「已宣告純文字」與「上游錯誤兜底」兩條事實驅動路徑,避免內建清單將多模態模型誤判為純文字而靜默丟失圖片\"\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"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 \"sudocode\": \"SudoCode 是全球一流 AI 模型聚合服務,一個 API Key 可通用多模型,並支援 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "73073454cd47b287d4566a7ef6034f0fafe4dd5d": {"t": 1785761116.181045, "n": 94274, "h": "3abe3a6c042affa2", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"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 \"sudocode\": \"SudoCode 是全球一流 AI 模型聚合服務,一個 API Key 可通用多模型,並支援 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "aeaa016cae2e7fb5dda0d4ab6c14d060c63471d4": {"t": 1785761118.0536273, "n": 94273, "h": "e8aa482c74f8be10", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\n \"codexAuth\": \"Codex 應用增強\",\n \"preserveCodexOfficialAuthOnSwitch\": \"切換第三方時保留官方登入\",\n \"preserveCodexOfficialAuthOnSwitchDescription\": \"開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"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 \"sudocode\": \"SudoCode 是全球一流 AI 模型聚合服務,一個 API Key 可通用多模型,並支援 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "a04e72a2674bbf8066705801f8778fdee1a4b8f3": {"t": 1785761121.3287685, "n": 94383, "h": "21df0b261b18d683", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\n \"codexAuth\": \"Codex 應用增強\",\n \"preserveCodexOfficialAuthOnSwitch\": \"切換第三方時保留官方登入\",\n \"preserveCodexOfficialAuthOnSwitchDescription\": \"開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"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 \"sudocode\": \"SudoCode 是全球一流 AI 模型聚合服務,一個 API Key 可通用多模型,並支援 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"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,不是即時 ~/.codex/auth.json;OAuth Access Token 會保留在 live auth.json 中。\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\n \"configTomlStorageHint\": \"代理接管中顯示的是供應商上游 config;即時 config.toml 會由代理暫時寫為本機位址和 PROXY_MANAGED。\",\n \"proxyTakeoverStorageNotice\": \"目前應用處於代理接管狀態。這裡編輯的是供應商儲存配置,真實 live 配置會由代理暫時接管;儲存不需要這裡顯示 127.0.0.1 或 PROXY_MANAGED。\",\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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "c9cadd6e09efdab2860aa925b9e9dab09fafbd16": {"t": 1785761123.6256626, "n": 94049, "h": "b3f78857e85a588b", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\n \"codexAuth\": \"Codex 應用增強\",\n \"preserveCodexOfficialAuthOnSwitch\": \"切換第三方時保留官方登入\",\n \"preserveCodexOfficialAuthOnSwitchDescription\": \"開啟後可以在使用第三方 API 的時候使用 Codex 應用的官方外掛、手機遠端操作等功能\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"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 \"sudocode\": \"SudoCode 是全球一流 AI 模型聚合服務,一個 API Key 可通用多模型,並支援 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 設定內容\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "41433cfab204135007542befb83e9840d24f81ae": {"t": 1785761125.328345, "n": 94095, "h": "e9f10ec2c8fc0318", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\n \"codexAuth\": \"Codex 認證\",\n \"preserveCodexOfficialAuthOnSwitch\": \"切換第三方時保留官方登入\",\n \"preserveCodexOfficialAuthOnSwitchDescription\": \"開啟後切換第三方 Codex 供應商只更新 config.toml,並保留 auth.json 中的 ChatGPT 登入狀態;關閉後會用目前供應商 auth 覆寫 auth.json。\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"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 \"sudocode\": \"SudoCode 是全球一流 AI 模型聚合服務,一個 API Key 可通用多模型,並支援 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 設定內容\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "2683af57cb64e64d6d47875303aa950deee56041": {"t": 1785761127.2339633, "n": 94047, "h": "ad933a375cc831c2", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\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 \"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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\n \"codexAuth\": \"Codex 認證\",\n \"preserveCodexOfficialAuthOnSwitch\": \"切換第三方時保留官方登入\",\n \"preserveCodexOfficialAuthOnSwitchDescription\": \"開啟後切換第三方 Codex 供應商只更新 config.toml,並保留 auth.json 中的 ChatGPT 登入狀態;關閉後會用目前供應商 auth 覆寫 auth.json。\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"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 \"sudocode\": \"SudoCode 是全球一流 AI 模型聚合服務,一個 API Key 可通用多模型,並支援 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 設定內容\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "94cc3d103a285823d9ca46414b6026796d050411": {"t": 1785761130.043697, "n": 93812, "h": "f3645b2127928786", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\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 \"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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"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 \"sudocode\": \"SudoCode 是全球一流 AI 模型聚合服務,一個 API Key 可通用多模型,並支援 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 設定內容\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "3a1542077053995037d728368bcb3a9c80103cb5": {"t": 1785761135.684581, "n": 93604, "h": "336fbb9dc1f96ee0", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\n \"claudeDesktop\": {\n \"officialNotice\": \"Claude Desktop 官方供應商使用應用程式內建的 1P 登入,無需設定 API Key 和 API 位址。\",\n \"mode\": \"模型處理方式\",\n \"modeDirect\": \"直連\",\n \"modeProxy\": \"需要路由\",\n \"modelMappingToggle\": \"需要模型對應\",\n \"modelMappingOffHint\": \"如果您的供應商提供的不是原生 Claude 系列模型,請開啟此開關。\",\n \"modelMappingOnHint\": \"Claude Desktop 目前對模型 ID 進行了限制,如果您的供應商提供的模型不是 Claude 系列模型,則需要開啟本開關,並在使用過程中保持本地路由開啟。\",\n \"routeMapTitle\": \"模型對應\",\n \"routeMapHint\": \"選擇模型角色後,CC Switch 會自動產生 Claude Desktop 相容路由;選單顯示名稱可以填寫 DeepSeek、Kimi 等品牌模型,實際請求模型依右側填寫內容發送。\",\n \"routeModelLabel\": \"模型角色\",\n \"routeRoleSonnet\": \"Sonnet\",\n \"routeRoleOpus\": \"Opus\",\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 可識別的 claude-* 模型名稱時填寫;勾選 1M 會向 Claude Desktop 聲明支援 1M 上下文。\",\n \"directModelInvalid\": \"直連模型必須使用 claude-* / anthropic/claude-* 模型名稱\",\n \"addModel\": \"新增模型\",\n \"addRoute\": \"新增模型\",\n \"routeInvalid\": \"請選擇模型角色並填寫實際請求模型\",\n \"routesRequired\": \"至少填寫一個模型對應\",\n \"statusTitle\": \"Claude Desktop 設定需要檢查\",\n \"statusUnsupported\": \"目前平台暫不支援 Claude Desktop 3P 設定寫入。\",\n \"statusStaleRawModels\": \"Claude Desktop profile 中存在非 claude-* 模型名稱,新版 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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"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 \"sudocode\": \"SudoCode 是全球一流 AI 模型聚合服務,一個 API Key 可通用多模型,並支援 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 設定內容\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "0877b9e35d10f1efa277a0e2d9dd1d2e78cff1dc": {"t": 1785761138.6319885, "n": 93604, "h": "341d57b55e7d3e67", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\n \"claudeDesktop\": {\n \"officialNotice\": \"Claude Desktop 官方供應商使用應用程式內建的 1P 登入,無需設定 API Key 和 API 位址。\",\n \"mode\": \"模型處理方式\",\n \"modeDirect\": \"直連\",\n \"modeProxy\": \"需要路由\",\n \"modelMappingToggle\": \"需要模型對應\",\n \"modelMappingOffHint\": \"如果您的供應商提供的不是原生 Claude 系列模型,請開啟此開關。\",\n \"modelMappingOnHint\": \"Claude Desktop 目前對模型 ID 進行了限制,如果您的供應商提供的模型不是 Claude 系列模型,則需要開啟本開關,並在使用過程中保持本地路由開啟。\",\n \"routeMapTitle\": \"模型對應\",\n \"routeMapHint\": \"選擇模型角色後,CC Switch 會自動產生 Claude Desktop 相容路由;選單顯示名稱可以填寫 DeepSeek、Kimi 等品牌模型,實際請求模型依右側填寫內容發送。\",\n \"routeModelLabel\": \"模型角色\",\n \"routeRoleSonnet\": \"Sonnet\",\n \"routeRoleOpus\": \"Opus\",\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 可識別的 claude-* 模型名稱時填寫;勾選 1M 會向 Claude Desktop 聲明支援 1M 上下文。\",\n \"directModelInvalid\": \"直連模型必須使用 claude-* / anthropic/claude-* 模型名稱\",\n \"addModel\": \"新增模型\",\n \"addRoute\": \"新增模型\",\n \"routeInvalid\": \"請選擇模型角色並填寫實際請求模型\",\n \"routesRequired\": \"至少填寫一個模型對應\",\n \"statusTitle\": \"Claude Desktop 設定需要檢查\",\n \"statusUnsupported\": \"目前平台暫不支援 Claude Desktop 3P 設定寫入。\",\n \"statusStaleRawModels\": \"Claude Desktop profile 中存在非 claude-* 模型名稱,新版 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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"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 \"sudocode\": \"SudoCode 是全球一流 AI 模型聚合服務,一個 API Key 可通用多模型,並支援 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 設定內容\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 Pro 模型。\",\n \"ultrabrain\": \"深度邏輯推理類別,用於需要廣泛分析的複雜架構決策,預設使用 GPT-5.3 Codex 的超高推理變體。\",\n \"deep\": \"深度自主問題解決類別,目標導向執行,行動前進行徹底研究,適用於需要深度理解的棘手問題。\",\n \"artistry\": \"高度創意與藝術性任務類別,激發新穎想法和創造性解決方案,預設使用 Gemini 3 Pro 的最大變體。\",\n \"quick\": \"輕量任務類別,用於單檔案修改、錯別字修復、簡單調整等瑣碎工作,預設使用 Claude Haiku 4.5 快速模型。\",\n \"unspecifiedLow\": \"未分類低工作量任務類別,適用於不適合其他類別且工作量較小的任務,預設使用 Claude Sonnet 4.5。\",\n \"unspecifiedHigh\": \"未分類高工作量任務類別,適用於不適合其他類別且工作量較大的任務,預設使用 Claude Opus 4.8 的最大變體。\",\n \"writing\": \"寫作類別,專注於文件、散文和技術寫作,預設使用 Gemini 3 Flash 快速產生模型。\"\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "e71b90916b5e0cfc1875f2779dd5f7ed2c7335fe": {"t": 1785761140.1916466, "n": 93604, "h": "646b09ca2a16bfa7", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\n \"claudeDesktop\": {\n \"officialNotice\": \"Claude Desktop 官方供應商使用應用程式內建的 1P 登入,無需設定 API Key 和 API 位址。\",\n \"mode\": \"模型處理方式\",\n \"modeDirect\": \"直連\",\n \"modeProxy\": \"需要路由\",\n \"modelMappingToggle\": \"需要模型對應\",\n \"modelMappingOffHint\": \"如果您的供應商提供的不是原生 Claude 系列模型,請開啟此開關。\",\n \"modelMappingOnHint\": \"Claude Desktop 目前對模型 ID 進行了限制,如果您的供應商提供的模型不是 Claude 系列模型,則需要開啟本開關,並在使用過程中保持本地路由開啟。\",\n \"routeMapTitle\": \"模型對應\",\n \"routeMapHint\": \"選擇模型角色後,CC Switch 會自動產生 Claude Desktop 相容路由;選單顯示名稱可以填寫 DeepSeek、Kimi 等品牌模型,實際請求模型依右側填寫內容發送。\",\n \"routeModelLabel\": \"模型角色\",\n \"routeRoleSonnet\": \"Sonnet\",\n \"routeRoleOpus\": \"Opus\",\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 可識別的 claude-* 模型名稱時填寫;勾選 1M 會向 Claude Desktop 聲明支援 1M 上下文。\",\n \"directModelInvalid\": \"直連模型必須使用 claude-* / anthropic/claude-* 模型名稱\",\n \"addModel\": \"新增模型\",\n \"addRoute\": \"新增模型\",\n \"routeInvalid\": \"請選擇模型角色並填寫實際請求模型\",\n \"routesRequired\": \"至少填寫一個模型對應\",\n \"statusTitle\": \"Claude Desktop 設定需要檢查\",\n \"statusUnsupported\": \"目前平台暫不支援 Claude Desktop 3P 設定寫入。\",\n \"statusStaleRawModels\": \"Claude Desktop profile 中存在非 claude-* 模型名稱,新版 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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"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 \"sudocode\": \"SudoCode 是全球一流 AI 模型聚合服務,一個 API Key 可通用多模型,並支援 Claude Code、Codex、Gemini CLI、OpenClaw 等工具接入。\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 設定內容\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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-7\",\n \"modelName\": \"顯示名稱\",\n \"modelNamePlaceholder\": \"Claude Opus 4.7\",\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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 Pro 模型。\",\n \"ultrabrain\": \"深度邏輯推理類別,用於需要廣泛分析的複雜架構決策,預設使用 GPT-5.3 Codex 的超高推理變體。\",\n \"deep\": \"深度自主問題解決類別,目標導向執行,行動前進行徹底研究,適用於需要深度理解的棘手問題。\",\n \"artistry\": \"高度創意與藝術性任務類別,激發新穎想法和創造性解決方案,預設使用 Gemini 3 Pro 的最大變體。\",\n \"quick\": \"輕量任務類別,用於單檔案修改、錯別字修復、簡單調整等瑣碎工作,預設使用 Claude Haiku 4.5 快速模型。\",\n \"unspecifiedLow\": \"未分類低工作量任務類別,適用於不適合其他類別且工作量較小的任務,預設使用 Claude Sonnet 4.5。\",\n \"unspecifiedHigh\": \"未分類高工作量任務類別,適用於不適合其他類別且工作量較大的任務,預設使用 Claude Opus 4.7 的最大變體。\",\n \"writing\": \"寫作類別,專注於文件、散文和技術寫作,預設使用 Gemini 3 Flash 快速產生模型。\"\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "32b30e43c0a700d6aab4d83ad1e8e8952f405510": {"t": 1785761143.176298, "n": 93491, "h": "f938e60bdbb721db", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\n \"claudeDesktop\": {\n \"officialNotice\": \"Claude Desktop 官方供應商使用應用程式內建的 1P 登入,無需設定 API Key 和 API 位址。\",\n \"mode\": \"模型處理方式\",\n \"modeDirect\": \"直連\",\n \"modeProxy\": \"需要路由\",\n \"modelMappingToggle\": \"需要模型對應\",\n \"modelMappingOffHint\": \"如果您的供應商提供的不是原生 Claude 系列模型,請開啟此開關。\",\n \"modelMappingOnHint\": \"Claude Desktop 目前對模型 ID 進行了限制,如果您的供應商提供的模型不是 Claude 系列模型,則需要開啟本開關,並在使用過程中保持本地路由開啟。\",\n \"routeMapTitle\": \"模型對應\",\n \"routeMapHint\": \"選擇模型角色後,CC Switch 會自動產生 Claude Desktop 相容路由;選單顯示名稱可以填寫 DeepSeek、Kimi 等品牌模型,實際請求模型依右側填寫內容發送。\",\n \"routeModelLabel\": \"模型角色\",\n \"routeRoleSonnet\": \"Sonnet\",\n \"routeRoleOpus\": \"Opus\",\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 可識別的 claude-* 模型名稱時填寫;勾選 1M 會向 Claude Desktop 聲明支援 1M 上下文。\",\n \"directModelInvalid\": \"直連模型必須使用 claude-* / anthropic/claude-* 模型名稱\",\n \"addModel\": \"新增模型\",\n \"addRoute\": \"新增模型\",\n \"routeInvalid\": \"請選擇模型角色並填寫實際請求模型\",\n \"routesRequired\": \"至少填寫一個模型對應\",\n \"statusTitle\": \"Claude Desktop 設定需要檢查\",\n \"statusUnsupported\": \"目前平台暫不支援 Claude Desktop 3P 設定寫入。\",\n \"statusStaleRawModels\": \"Claude Desktop profile 中存在非 claude-* 模型名稱,新版 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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 設定內容\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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-7\",\n \"modelName\": \"顯示名稱\",\n \"modelNamePlaceholder\": \"Claude Opus 4.7\",\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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 Pro 模型。\",\n \"ultrabrain\": \"深度邏輯推理類別,用於需要廣泛分析的複雜架構決策,預設使用 GPT-5.3 Codex 的超高推理變體。\",\n \"deep\": \"深度自主問題解決類別,目標導向執行,行動前進行徹底研究,適用於需要深度理解的棘手問題。\",\n \"artistry\": \"高度創意與藝術性任務類別,激發新穎想法和創造性解決方案,預設使用 Gemini 3 Pro 的最大變體。\",\n \"quick\": \"輕量任務類別,用於單檔案修改、錯別字修復、簡單調整等瑣碎工作,預設使用 Claude Haiku 4.5 快速模型。\",\n \"unspecifiedLow\": \"未分類低工作量任務類別,適用於不適合其他類別且工作量較小的任務,預設使用 Claude Sonnet 4.5。\",\n \"unspecifiedHigh\": \"未分類高工作量任務類別,適用於不適合其他類別且工作量較大的任務,預設使用 Claude Opus 4.7 的最大變體。\",\n \"writing\": \"寫作類別,專注於文件、散文和技術寫作,預設使用 Gemini 3 Flash 快速產生模型。\"\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "9ef141902e3c311cdbcddeeb3c0afb9a7402c11f": {"t": 1785761144.8780046, "n": 93376, "h": "d333ed0a31c4558a", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\n \"claudeDesktop\": {\n \"officialNotice\": \"Claude Desktop 官方供應商使用應用程式內建的 1P 登入,無需設定 API Key 和 API 位址。\",\n \"mode\": \"模型處理方式\",\n \"modeDirect\": \"直連\",\n \"modeProxy\": \"需要路由\",\n \"modelMappingToggle\": \"需要模型對應\",\n \"modelMappingOffHint\": \"如果您的供應商提供的不是原生 Claude 系列模型,請開啟此開關。\",\n \"modelMappingOnHint\": \"Claude Desktop 目前對模型 ID 進行了限制,如果您的供應商提供的模型不是 Claude 系列模型,則需要開啟本開關,並在使用過程中保持本地路由開啟。\",\n \"routeMapTitle\": \"模型對應\",\n \"routeMapHint\": \"選擇模型角色後,CC Switch 會自動產生 Claude Desktop 相容路由;選單顯示名稱可以填寫 DeepSeek、Kimi 等品牌模型,實際請求模型依右側填寫內容發送。\",\n \"routeModelLabel\": \"模型角色\",\n \"routeRoleSonnet\": \"Sonnet\",\n \"routeRoleOpus\": \"Opus\",\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 可識別的 claude-* 模型名稱時填寫;勾選 1M 會向 Claude Desktop 聲明支援 1M 上下文。\",\n \"directModelInvalid\": \"直連模型必須使用 claude-* / anthropic/claude-* 模型名稱\",\n \"addModel\": \"新增模型\",\n \"addRoute\": \"新增模型\",\n \"routeInvalid\": \"請選擇模型角色並填寫實際請求模型\",\n \"routesRequired\": \"至少填寫一個模型對應\",\n \"statusTitle\": \"Claude Desktop 設定需要檢查\",\n \"statusUnsupported\": \"目前平台暫不支援 Claude Desktop 3P 設定寫入。\",\n \"statusStaleRawModels\": \"Claude Desktop profile 中存在非 claude-* 模型名稱,新版 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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"packycode\": \"PackyCode 是 CC Switch 的官方合作夥伴,使用此連結註冊並在儲值時填寫「cc-switch」優惠碼,可以享受 9 折優惠\",\n \"apikeyfun\": \"APIKEY.FUN 為 CC Switch 的使用者提供了特別優惠,透過專屬連結註冊,可享受最高儲值永久 95 折優惠。\",\n \"apinebula\": \"APINEBULA 為 CC Switch 使用者提供特別優惠:使用專屬連結註冊並在儲值時填寫「ccswitch」優惠碼,可享 9 折優惠。\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 設定內容\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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-7\",\n \"modelName\": \"顯示名稱\",\n \"modelNamePlaceholder\": \"Claude Opus 4.7\",\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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 Pro 模型。\",\n \"ultrabrain\": \"深度邏輯推理類別,用於需要廣泛分析的複雜架構決策,預設使用 GPT-5.3 Codex 的超高推理變體。\",\n \"deep\": \"深度自主問題解決類別,目標導向執行,行動前進行徹底研究,適用於需要深度理解的棘手問題。\",\n \"artistry\": \"高度創意與藝術性任務類別,激發新穎想法和創造性解決方案,預設使用 Gemini 3 Pro 的最大變體。\",\n \"quick\": \"輕量任務類別,用於單檔案修改、錯別字修復、簡單調整等瑣碎工作,預設使用 Claude Haiku 4.5 快速模型。\",\n \"unspecifiedLow\": \"未分類低工作量任務類別,適用於不適合其他類別且工作量較小的任務,預設使用 Claude Sonnet 4.5。\",\n \"unspecifiedHigh\": \"未分類高工作量任務類別,適用於不適合其他類別且工作量較大的任務,預設使用 Claude Opus 4.7 的最大變體。\",\n \"writing\": \"寫作類別,專注於文件、散文和技術寫作,預設使用 Gemini 3 Flash 快速產生模型。\"\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "8302f1e36c6834fc4db7e0228206e291727fe787": {"t": 1785761147.6048152, "n": 93283, "h": "9f0df55896c6557b", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\n \"claudeDesktop\": {\n \"officialNotice\": \"Claude Desktop 官方供應商使用應用程式內建的 1P 登入,無需設定 API Key 和 API 位址。\",\n \"mode\": \"模型處理方式\",\n \"modeDirect\": \"直連\",\n \"modeProxy\": \"需要路由\",\n \"modelMappingToggle\": \"需要模型對應\",\n \"modelMappingOffHint\": \"如果您的供應商提供的不是原生 Claude 系列模型,請開啟此開關。\",\n \"modelMappingOnHint\": \"Claude Desktop 目前對模型 ID 進行了限制,如果您的供應商提供的模型不是 Claude 系列模型,則需要開啟本開關,並在使用過程中保持本地路由開啟。\",\n \"routeMapTitle\": \"模型對應\",\n \"routeMapHint\": \"選擇模型角色後,CC Switch 會自動產生 Claude Desktop 相容路由;選單顯示名稱可以填寫 DeepSeek、Kimi 等品牌模型,實際請求模型依右側填寫內容發送。\",\n \"routeModelLabel\": \"模型角色\",\n \"routeRoleSonnet\": \"Sonnet\",\n \"routeRoleOpus\": \"Opus\",\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 可識別的 claude-* 模型名稱時填寫;勾選 1M 會向 Claude Desktop 聲明支援 1M 上下文。\",\n \"directModelInvalid\": \"直連模型必須使用 claude-* / anthropic/claude-* 模型名稱\",\n \"addModel\": \"新增模型\",\n \"addRoute\": \"新增模型\",\n \"routeInvalid\": \"請選擇模型角色並填寫實際請求模型\",\n \"routesRequired\": \"至少填寫一個模型對應\",\n \"statusTitle\": \"Claude Desktop 設定需要檢查\",\n \"statusUnsupported\": \"目前平台暫不支援 Claude Desktop 3P 設定寫入。\",\n \"statusStaleRawModels\": \"Claude Desktop profile 中存在非 claude-* 模型名稱,新版 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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"packycode\": \"PackyCode 是 CC Switch 的官方合作夥伴,使用此連結註冊並在儲值時填寫「cc-switch」優惠碼,可以享受 9 折優惠\",\n \"apikeyfun\": \"APIKEY.FUN 為 CC Switch 的使用者提供了特別優惠,透過專屬連結註冊,可享受最高儲值永久 95 折優惠。\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 設定內容\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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-7\",\n \"modelName\": \"顯示名稱\",\n \"modelNamePlaceholder\": \"Claude Opus 4.7\",\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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 Pro 模型。\",\n \"ultrabrain\": \"深度邏輯推理類別,用於需要廣泛分析的複雜架構決策,預設使用 GPT-5.3 Codex 的超高推理變體。\",\n \"deep\": \"深度自主問題解決類別,目標導向執行,行動前進行徹底研究,適用於需要深度理解的棘手問題。\",\n \"artistry\": \"高度創意與藝術性任務類別,激發新穎想法和創造性解決方案,預設使用 Gemini 3 Pro 的最大變體。\",\n \"quick\": \"輕量任務類別,用於單檔案修改、錯別字修復、簡單調整等瑣碎工作,預設使用 Claude Haiku 4.5 快速模型。\",\n \"unspecifiedLow\": \"未分類低工作量任務類別,適用於不適合其他類別且工作量較小的任務,預設使用 Claude Sonnet 4.5。\",\n \"unspecifiedHigh\": \"未分類高工作量任務類別,適用於不適合其他類別且工作量較大的任務,預設使用 Claude Opus 4.7 的最大變體。\",\n \"writing\": \"寫作類別,專注於文件、散文和技術寫作,預設使用 Gemini 3 Flash 快速產生模型。\"\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "af60c7ed2c481ff6469e9a506a10cf3bed4f6380": {"t": 1785761149.2166762, "n": 93199, "h": "5a32a0682be42541", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\n \"claudeDesktop\": {\n \"officialNotice\": \"Claude Desktop 官方供應商使用應用程式內建的 1P 登入,無需設定 API Key 和 API 位址。\",\n \"mode\": \"模型處理方式\",\n \"modeDirect\": \"直連\",\n \"modeProxy\": \"需要路由\",\n \"modelMappingToggle\": \"需要模型對應\",\n \"modelMappingOffHint\": \"如果您的供應商提供的不是原生 Claude 系列模型,請開啟此開關。\",\n \"modelMappingOnHint\": \"Claude Desktop 目前對模型 ID 進行了限制,如果您的供應商提供的模型不是 Claude 系列模型,則需要開啟本開關,並在使用過程中保持本地路由開啟。\",\n \"routeMapTitle\": \"模型對應\",\n \"routeMapHint\": \"選擇模型角色後,CC Switch 會自動產生 Claude Desktop 相容路由;選單顯示名稱可以填寫 DeepSeek、Kimi 等品牌模型,實際請求模型依右側填寫內容發送。\",\n \"routeModelLabel\": \"模型角色\",\n \"routeRoleSonnet\": \"Sonnet\",\n \"routeRoleOpus\": \"Opus\",\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 可識別的 claude-* 模型名稱時填寫;勾選 1M 會向 Claude Desktop 聲明支援 1M 上下文。\",\n \"directModelInvalid\": \"直連模型必須使用 claude-* / anthropic/claude-* 模型名稱\",\n \"addModel\": \"新增模型\",\n \"addRoute\": \"新增模型\",\n \"routeInvalid\": \"請選擇模型角色並填寫實際請求模型\",\n \"routesRequired\": \"至少填寫一個模型對應\",\n \"statusTitle\": \"Claude Desktop 設定需要檢查\",\n \"statusUnsupported\": \"目前平台暫不支援 Claude Desktop 3P 設定寫入。\",\n \"statusStaleRawModels\": \"Claude Desktop profile 中存在非 claude-* 模型名稱,新版 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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"packycode\": \"PackyCode 是 CC Switch 的官方合作夥伴,使用此連結註冊並在儲值時填寫「cc-switch」優惠碼,可以享受 9 折優惠\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 設定內容\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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-7\",\n \"modelName\": \"顯示名稱\",\n \"modelNamePlaceholder\": \"Claude Opus 4.7\",\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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 Pro 模型。\",\n \"ultrabrain\": \"深度邏輯推理類別,用於需要廣泛分析的複雜架構決策,預設使用 GPT-5.3 Codex 的超高推理變體。\",\n \"deep\": \"深度自主問題解決類別,目標導向執行,行動前進行徹底研究,適用於需要深度理解的棘手問題。\",\n \"artistry\": \"高度創意與藝術性任務類別,激發新穎想法和創造性解決方案,預設使用 Gemini 3 Pro 的最大變體。\",\n \"quick\": \"輕量任務類別,用於單檔案修改、錯別字修復、簡單調整等瑣碎工作,預設使用 Claude Haiku 4.5 快速模型。\",\n \"unspecifiedLow\": \"未分類低工作量任務類別,適用於不適合其他類別且工作量較小的任務,預設使用 Claude Sonnet 4.5。\",\n \"unspecifiedHigh\": \"未分類高工作量任務類別,適用於不適合其他類別且工作量較大的任務,預設使用 Claude Opus 4.7 的最大變體。\",\n \"writing\": \"寫作類別,專注於文件、散文和技術寫作,預設使用 Gemini 3 Flash 快速產生模型。\"\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "6b0dd3c4e9e41824f12aa259e647dbfcf0275091": {"t": 1785761151.1307044, "n": 93067, "h": "940f7723c7e486b4", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\n \"claudeDesktop\": {\n \"officialNotice\": \"Claude Desktop 官方供應商使用應用程式內建的 1P 登入,無需設定 API Key 和 API 位址。\",\n \"mode\": \"模型處理方式\",\n \"modeDirect\": \"直連\",\n \"modeProxy\": \"需要路由\",\n \"modelMappingToggle\": \"需要模型對應\",\n \"modelMappingOffHint\": \"如果您的供應商提供的不是原生 Claude 系列模型,請開啟此開關。\",\n \"modelMappingOnHint\": \"Claude Desktop 目前對模型 ID 進行了限制,如果您的供應商提供的模型不是 Claude 系列模型,則需要開啟本開關,並在使用過程中保持本地路由開啟。\",\n \"routeMapTitle\": \"模型對應\",\n \"routeMapHint\": \"選擇模型角色後,CC Switch 會自動產生 Claude Desktop 相容路由;選單顯示名稱可以填寫 DeepSeek、Kimi 等品牌模型,實際請求模型依右側填寫內容發送。\",\n \"routeModelLabel\": \"模型角色\",\n \"routeRoleSonnet\": \"Sonnet\",\n \"routeRoleOpus\": \"Opus\",\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 可識別的 claude-* 模型名稱時填寫;勾選 1M 會向 Claude Desktop 聲明支援 1M 上下文。\",\n \"directModelInvalid\": \"直連模型必須使用 claude-* / anthropic/claude-* 模型名稱\",\n \"addModel\": \"新增模型\",\n \"addRoute\": \"新增模型\",\n \"routeInvalid\": \"請選擇模型角色並填寫實際請求模型\",\n \"routesRequired\": \"至少填寫一個模型對應\",\n \"statusTitle\": \"Claude Desktop 設定需要檢查\",\n \"statusUnsupported\": \"目前平台暫不支援 Claude Desktop 3P 設定寫入。\",\n \"statusStaleRawModels\": \"Claude Desktop profile 中存在非 claude-* 模型名稱,新版 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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"packycode\": \"PackyCode 是 CC Switch 的官方合作夥伴,使用此連結註冊並在儲值時填寫「cc-switch」優惠碼,可以享受 9 折優惠\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 設定內容\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\n \"writeCommonConfig\": \"寫入通用設定\",\n \"editCommonConfig\": \"編輯通用設定\",\n \"editCommonConfigTitle\": \"編輯 Codex 通用設定片段\",\n \"commonConfigHint\": \"該片段會在勾選「寫入通用設定」時附加到 config.toml 末尾\",\n \"enableGoalMode\": \"啟用 Goal mode\",\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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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-7\",\n \"modelName\": \"顯示名稱\",\n \"modelNamePlaceholder\": \"Claude Opus 4.7\",\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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 Pro 模型。\",\n \"ultrabrain\": \"深度邏輯推理類別,用於需要廣泛分析的複雜架構決策,預設使用 GPT-5.3 Codex 的超高推理變體。\",\n \"deep\": \"深度自主問題解決類別,目標導向執行,行動前進行徹底研究,適用於需要深度理解的棘手問題。\",\n \"artistry\": \"高度創意與藝術性任務類別,激發新穎想法和創造性解決方案,預設使用 Gemini 3 Pro 的最大變體。\",\n \"quick\": \"輕量任務類別,用於單檔案修改、錯別字修復、簡單調整等瑣碎工作,預設使用 Claude Haiku 4.5 快速模型。\",\n \"unspecifiedLow\": \"未分類低工作量任務類別,適用於不適合其他類別且工作量較小的任務,預設使用 Claude Sonnet 4.5。\",\n \"unspecifiedHigh\": \"未分類高工作量任務類別,適用於不適合其他類別且工作量較大的任務,預設使用 Claude Opus 4.7 的最大變體。\",\n \"writing\": \"寫作類別,專注於文件、散文和技術寫作,預設使用 Gemini 3 Flash 快速產生模型。\"\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "3c3d417457a4c3420139488c19718b7415641584": {"t": 1785761153.3423662, "n": 92932, "h": "1694849fb82326eb", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\n \"claudeDesktop\": {\n \"officialNotice\": \"Claude Desktop 官方供應商使用應用程式內建的 1P 登入,無需設定 API Key 和 API 位址。\",\n \"mode\": \"模型處理方式\",\n \"modeDirect\": \"直連\",\n \"modeProxy\": \"需要路由\",\n \"modelMappingToggle\": \"需要模型對應\",\n \"modelMappingOffHint\": \"如果您的供應商提供的不是原生 Claude 系列模型,請開啟此開關。\",\n \"modelMappingOnHint\": \"Claude Desktop 目前對模型 ID 進行了限制,如果您的供應商提供的模型不是 Claude 系列模型,則需要開啟本開關,並在使用過程中保持本地路由開啟。\",\n \"routeMapTitle\": \"模型對應\",\n \"routeMapHint\": \"選擇模型角色後,CC Switch 會自動產生 Claude Desktop 相容路由;選單顯示名稱可以填寫 DeepSeek、Kimi 等品牌模型,實際請求模型依右側填寫內容發送。\",\n \"routeModelLabel\": \"模型角色\",\n \"routeRoleSonnet\": \"Sonnet\",\n \"routeRoleOpus\": \"Opus\",\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 可識別的 claude-* 模型名稱時填寫;勾選 1M 會向 Claude Desktop 聲明支援 1M 上下文。\",\n \"directModelInvalid\": \"直連模型必須使用 claude-* / anthropic/claude-* 模型名稱\",\n \"addModel\": \"新增模型\",\n \"addRoute\": \"新增模型\",\n \"routeInvalid\": \"請選擇模型角色並填寫實際請求模型\",\n \"routesRequired\": \"至少填寫一個模型對應\",\n \"statusTitle\": \"Claude Desktop 設定需要檢查\",\n \"statusUnsupported\": \"目前平台暫不支援 Claude Desktop 3P 設定寫入。\",\n \"statusStaleRawModels\": \"Claude Desktop profile 中存在非 claude-* 模型名稱,新版 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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"packycode\": \"PackyCode 是 CC Switch 的官方合作夥伴,使用此連結註冊並在儲值時填寫「cc-switch」優惠碼,可以享受 9 折優惠\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 設定內容\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\n \"writeCommonConfig\": \"寫入通用設定\",\n \"editCommonConfig\": \"編輯通用設定\",\n \"editCommonConfigTitle\": \"編輯 Codex 通用設定片段\",\n \"commonConfigHint\": \"該片段會在勾選「寫入通用設定」時附加到 config.toml 末尾\",\n \"enableGoalMode\": \"啟用 Goal mode\",\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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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-7\",\n \"modelName\": \"顯示名稱\",\n \"modelNamePlaceholder\": \"Claude Opus 4.7\",\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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 \"fillRecommendedAllSet\": \"所有槽位已有模型設定\",\n \"fillRecommendedNoMatch\": \"推薦模型在已設定的供應商中未找到相符項目\",\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 Pro 模型。\",\n \"ultrabrain\": \"深度邏輯推理類別,用於需要廣泛分析的複雜架構決策,預設使用 GPT-5.3 Codex 的超高推理變體。\",\n \"deep\": \"深度自主問題解決類別,目標導向執行,行動前進行徹底研究,適用於需要深度理解的棘手問題。\",\n \"artistry\": \"高度創意與藝術性任務類別,激發新穎想法和創造性解決方案,預設使用 Gemini 3 Pro 的最大變體。\",\n \"quick\": \"輕量任務類別,用於單檔案修改、錯別字修復、簡單調整等瑣碎工作,預設使用 Claude Haiku 4.5 快速模型。\",\n \"unspecifiedLow\": \"未分類低工作量任務類別,適用於不適合其他類別且工作量較小的任務,預設使用 Claude Sonnet 4.5。\",\n \"unspecifiedHigh\": \"未分類高工作量任務類別,適用於不適合其他類別且工作量較大的任務,預設使用 Claude Opus 4.7 的最大變體。\",\n \"writing\": \"寫作類別,專注於文件、散文和技術寫作,預設使用 Gemini 3 Flash 快速產生模型。\"\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}, "5fd3ec0d6a0f33d41d99866a70a8bd954e46659a": {"t": 1785761159.619464, "n": 92894, "h": "bf1a3365ab1f4989", "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 \"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 \"blockedByProxy\": \"已攔截\",\n \"editProvider\": \"編輯供應商\",\n \"editProviderHint\": \"更新設定後將立即套用至目前供應商。\",\n \"deleteProvider\": \"刪除供應商\",\n \"addNewProvider\": \"新增供應商\",\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 },\n \"claudeDesktop\": {\n \"officialNotice\": \"Claude Desktop 官方供應商使用應用程式內建的 1P 登入,無需設定 API Key 和 API 位址。\",\n \"mode\": \"模型處理方式\",\n \"modeDirect\": \"直連\",\n \"modeProxy\": \"需要路由\",\n \"modelMappingToggle\": \"需要模型對應\",\n \"modelMappingOffHint\": \"如果您的供應商提供的不是原生 Claude 系列模型,請開啟此開關。\",\n \"modelMappingOnHint\": \"Claude Desktop 目前對模型 ID 進行了限制,如果您的供應商提供的模型不是 Claude 系列模型,則需要開啟本開關,並在使用過程中保持本地路由開啟。\",\n \"routeMapTitle\": \"模型對應\",\n \"routeMapHint\": \"選擇模型角色後,CC Switch 會自動產生 Claude Desktop 相容路由;選單顯示名稱可以填寫 DeepSeek、Kimi 等品牌模型,實際請求模型依右側填寫內容發送。\",\n \"routeModelLabel\": \"模型角色\",\n \"routeRoleSonnet\": \"Sonnet\",\n \"routeRoleOpus\": \"Opus\",\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 可識別的 claude-* 模型名稱時填寫;勾選 1M 會向 Claude Desktop 聲明支援 1M 上下文。\",\n \"directModelInvalid\": \"直連模型必須使用 claude-* / anthropic/claude-* 模型名稱\",\n \"addModel\": \"新增模型\",\n \"addRoute\": \"新增模型\",\n \"routeInvalid\": \"請選擇模型角色並填寫實際請求模型\",\n \"routesRequired\": \"至少填寫一個模型對應\",\n \"statusTitle\": \"Claude Desktop 設定需要檢查\",\n \"statusUnsupported\": \"目前平台暫不支援 Claude Desktop 3P 設定寫入。\",\n \"statusStaleRawModels\": \"Claude Desktop profile 中存在非 claude-* 模型名稱,新版 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 \"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 \"usage\": {\n \"title\": \"設定用量查詢\",\n \"message\": \"用量查詢需要設定專用的查詢腳本或 API 參數,請確保您已從供應商處取得相關資訊。\\n\\n如不確定如何設定,請先查閱供應商文件。\",\n \"confirm\": \"我已了解,繼續設定\"\n },\n \"streamCheck\": {\n \"title\": \"模型健康檢測\",\n \"message\": \"健康檢測透過直接發送 API 請求來測試供應商連線能力,以下情況可能導致檢測失敗:\\n\\n• 官方供應商(使用 OAuth 登入,無獨立 API Key)\\n• 部分中繼服務(會驗證請求是否來自 Claude Code CLI)\\n• AWS Bedrock(使用 IAM 簽章驗證)\\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 \"modelTest\": {\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 },\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\": \"自動在請求關鍵位置注入 Cache 斷點,減少重複 token 計費\",\n \"cacheTtl\": \"Cache TTL\",\n \"cacheTtl5m\": \"5 分鐘\",\n \"cacheTtl1h\": \"1 小時\"\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 \"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\": \"開啟後 Vscode Claude Code 外掛程式的供應商將隨本軟體切換\",\n \"skipClaudeOnboarding\": \"跳過 Claude Code 初次安裝確認\",\n \"skipClaudeOnboardingDescription\": \"開啟後跳過 Claude Code 初次安裝確認\",\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 },\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。\",\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 },\n \"sessionManager\": {\n \"title\": \"工作階段管理\",\n \"subtitle\": \"管理 Claude Code、Codex、OpenCode、OpenClaw、Hermes 與 Gemini CLI 工作階段紀錄\",\n \"searchPlaceholder\": \"搜尋對話內容、目錄或 ID\",\n \"searchSessions\": \"搜尋工作階段\",\n \"providerFilterAll\": \"全部\",\n \"sessionList\": \"工作階段清單\",\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 \"packycode\": \"PackyCode 是 CC Switch 的官方合作夥伴,使用此連結註冊並在儲值時填寫「cc-switch」優惠碼,可以享受 9 折優惠\",\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 \"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 \"ctok\": \"官網加入 CTok 社群,訂閱方案。\",\n \"shengsuanyun\": \"勝算雲為 CC Switch 的使用者提供了特別福利,使用此連結註冊的新使用者可獲 10 元模力及首儲 10% 贈送!\",\n \"lemondata\": \"Lemon Code 為 CC Switch 的使用者提供了特別優惠。使用此連結註冊可以獲得 1 美元免費額度。\",\n \"doubaoseed\": \"豆包大模型為 CC Switch 的使用者提供了專屬福利:透過此連結註冊即可取得豆包全系列文本模型 50 萬 tokens 推理額度以及 Seedance 2.0 的 500 萬 tokens 免費額度\",\n \"volcengine_agentplan\": \"火山方舟 Agent Plan 為 CC Switch 的使用者提供了專屬福利:透過此連結訂閱方舟 AgentPlan,新客戶首月 40 元起。\",\n \"byteplus\": \"透過此連結註冊即可取得每個模型 50 萬 tokens 的免費推理額度。\"\n },\n \"presets\": {\n \"ucloud\": \"優雲智算\",\n \"ucloudCoding\": \"優雲智算 Coding Plan\",\n \"shengsuanyun\": \"勝算雲\",\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 \"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 \"modelRoleHaiku\": \"Haiku\",\n \"modelDisplayNameLabel\": \"顯示名稱\",\n \"modelDisplayNamePlaceholder\": \"例如 DeepSeek V4 Pro\",\n \"requestModelLabel\": \"實際請求模型\",\n \"modelOneMHeader\": \"聲明支援 1M\",\n \"modelOneMLabel\": \"1M\",\n \"fallbackModelLabel\": \"備用模型\",\n \"fallbackModelHint\": \"僅在 Claude Code 請求沒有明確落到 Sonnet、Opus 或 Haiku 角色時使用;通常可以留空。\",\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 \"testConfig\": \"模型測試設定\",\n \"useCustomConfig\": \"使用獨立設定\",\n \"testConfigDesc\": \"為此供應商設定獨立的模型測試參數,不啟用時使用全域設定。\",\n \"testModel\": \"測試模型\",\n \"testModelPlaceholder\": \"留空使用全域設定\",\n \"timeoutSecs\": \"逾時時間(秒)\",\n \"testPrompt\": \"測試提示詞\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"maxRetries\": \"最大重試次數\",\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 \"codexConfig\": {\n \"authJson\": \"auth.json (JSON) *\",\n \"authJsonPlaceholder\": \"{\\n \\\"OPENAI_API_KEY\\\": \\\"sk-your-api-key-here\\\"\\n}\",\n \"authJsonHint\": \"Codex auth.json 設定內容\",\n \"configToml\": \"config.toml (TOML)\",\n \"configTomlHint\": \"Codex config.toml 設定內容\",\n \"writeCommonConfig\": \"寫入通用設定\",\n \"editCommonConfig\": \"編輯通用設定\",\n \"editCommonConfigTitle\": \"編輯 Codex 通用設定片段\",\n \"commonConfigHint\": \"該片段會在勾選「寫入通用設定」時附加到 config.toml 末尾\",\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 },\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 \"providerKey\": \"供應商標識\",\n \"providerKeyPlaceholder\": \"my-provider\",\n \"providerKeyHint\": \"設定檔中的唯一識別碼,只能使用小寫字母、數字和連字號\",\n \"providerKeyLockedHint\": \"該供應商已新增至應用程式設定中,供應商標識不可修改\",\n \"providerKeyRequired\": \"請填寫供應商標識\",\n \"providerKeyDuplicate\": \"此標識已被使用,請更換\",\n \"providerKeyInvalid\": \"標識格式無效,只能使用小寫字母、數字和連字號\",\n \"extraOptions\": \"額外選項\",\n \"extraOptionsHint\": \"設定額外的 SDK 選項,如 timeout、setCacheKey 等。值會自動解析類型(數字、布林值等)。\",\n \"addExtraOption\": \"新增\",\n \"extraOptionKey\": \"鍵名\",\n \"extraOptionValue\": \"值\",\n \"extraOptionKeyPlaceholder\": \"timeout\",\n \"extraOptionValuePlaceholder\": \"600000\",\n \"noExtraOptions\": \"暫無額外選項\",\n \"noModelOptions\": \"模型選項,點擊 + 新增\",\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 },\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 \"tokensSuffix\": \"Tokens\",\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 },\n \"rawInputLabel\": \"原始\",\n \"dataSources\": \"資料來源\",\n \"dataSource\": {\n \"proxy\": \"路由\",\n \"session_log\": \"工作階段日誌\",\n \"codex_db\": \"Codex 資料庫\",\n \"codex_session\": \"Codex 工作階段日誌\",\n \"gemini_session\": \"Gemini 工作階段日誌\"\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 \"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 \"allApps\": \"全部應用程式\",\n \"statusCode\": \"狀態碼\",\n \"searchProviderPlaceholder\": \"搜尋供應商...\",\n \"searchModelPlaceholder\": \"搜尋模型...\",\n \"timeRange\": \"時間範圍\",\n \"customRange\": \"日曆篩選\",\n \"customRangeHint\": \"支援日期與時間\",\n \"startTime\": \"開始時間\",\n \"endTime\": \"結束時間\",\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 \"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 \"copilotAutoAuth\": \"自動使用 OAuth 驗證,無需手動設定憑證\",\n \"tokenPlanHint\": \"自動使用供應商的 API Key 和 Base URL 查詢 Token Plan 額度\",\n \"balanceHint\": \"自動使用供應商的 API Key 查詢帳號餘額\",\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 \"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 \"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 \"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-7\",\n \"modelName\": \"顯示名稱\",\n \"modelNamePlaceholder\": \"Claude Opus 4.7\",\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 \"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 \"modelTest\": {\n \"testProvider\": \"測試模型\"\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 \"configSaved\": \"健康檢測設定已儲存\",\n \"configSaveFailed\": \"儲存失敗\",\n \"testModels\": \"測試模型\",\n \"claudeModel\": \"Claude 模型\",\n \"codexModel\": \"Codex 模型\",\n \"geminiModel\": \"Gemini 模型\",\n \"checkParams\": \"檢查參數\",\n \"timeout\": \"逾時時間(秒)\",\n \"maxRetries\": \"最大重試次數\",\n \"degradedThreshold\": \"降級閾值(毫秒)\",\n \"testPrompt\": \"檢查提示詞\",\n \"operational\": \"{{providerName}} 執行正常 ({{responseTimeMs}}ms)\",\n \"degraded\": \"{{providerName}} 回應較慢 ({{responseTimeMs}}ms)\",\n \"failed\": \"{{providerName}} 檢查失敗:{{message}}\",\n \"rejected\": \"{{providerName}} 檢查被拒:{{message}}\",\n \"error\": \"{{providerName}} 檢查出錯:{{error}}\",\n \"modelNotFound\": \"{{providerName}} 測試模型 {{model}} 不存在或已下架\",\n \"modelNotFoundHint\": \"該模型可能已被供應商棄用。請在「模型測試設定」中更新預設測試模型。\",\n \"quotaExceeded\": \"{{providerName}} Coding Plan 額度已用盡\",\n \"quotaExceededHint\": \"百度千帆回傳了 Coding Plan 超額錯誤。請等待 5 小時 / 每週 / 每月額度重新整理,或在千帆控制台切換方案。\",\n \"httpHint\": {\n \"400\": \"供應商拒絕了請求格式。健康檢測的探測格式可能與實際使用不同。\",\n \"401\": \"API Key 可能無效,或供應商使用 OAuth 等驗證方式。檢查失敗不代表實際不可用。\",\n \"402\": \"帳號配額或計費問題。\",\n \"403\": \"供應商拒絕了此請求。部分供應商會驗證用戶端身分,檢查失敗但實際使用可能正常。\",\n \"404\": \"API 位址不存在,請檢查 Base URL 和 API 路徑。\",\n \"429\": \"請求頻率過高,請稍後重試。\",\n \"5xx\": \"供應商內部錯誤,通常是臨時問題。\"\n }\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 \"updated\": \"通用供應商已更新\",\n \"deleted\": \"通用供應商已刪除\",\n \"addSuccess\": \"通用供應商新增成功\",\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 \"fillRecommendedAllSet\": \"所有槽位已有模型設定\",\n \"fillRecommendedNoMatch\": \"推薦模型在已設定的供應商中未找到相符項目\",\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 Pro 模型。\",\n \"ultrabrain\": \"深度邏輯推理類別,用於需要廣泛分析的複雜架構決策,預設使用 GPT-5.3 Codex 的超高推理變體。\",\n \"deep\": \"深度自主問題解決類別,目標導向執行,行動前進行徹底研究,適用於需要深度理解的棘手問題。\",\n \"artistry\": \"高度創意與藝術性任務類別,激發新穎想法和創造性解決方案,預設使用 Gemini 3 Pro 的最大變體。\",\n \"quick\": \"輕量任務類別,用於單檔案修改、錯別字修復、簡單調整等瑣碎工作,預設使用 Claude Haiku 4.5 快速模型。\",\n \"unspecifiedLow\": \"未分類低工作量任務類別,適用於不適合其他類別且工作量較小的任務,預設使用 Claude Sonnet 4.5。\",\n \"unspecifiedHigh\": \"未分類高工作量任務類別,適用於不適合其他類別且工作量較大的任務,預設使用 Claude Opus 4.7 的最大變體。\",\n \"writing\": \"寫作類別,專注於文件、散文和技術寫作,預設使用 Gemini 3 Flash 快速產生模型。\"\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 \"geminiPro\": \"Pro\",\n \"geminiFlash\": \"Flash\",\n \"geminiFlashLite\": \"Flash Lite\",\n \"weeklyLimit\": \"每週\",\n \"copilotPremium\": \"進階請求\",\n \"utilization\": \"{{value}}%\",\n \"resetsIn\": \"{{time}} 後重設\",\n \"extraUsage\": \"超額用量\",\n \"expired\": \"工作階段已過期\",\n \"expiredHint\": \"請執行 {{tool}} 命令重新整理登入\",\n \"queryFailed\": \"查詢失敗\",\n \"refresh\": \"重新整理\"\n }\n}\n"}}}}